evilution 1.3.0 → 1.4.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +62 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +77 -0
  5. data/README.md +80 -24
  6. data/docs/architecture.md +89 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +119 -16
  15. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  16. data/lib/evilution/ast/pattern/parser.rb +14 -15
  17. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  18. data/lib/evilution/ast/scope_declaration.rb +45 -0
  19. data/lib/evilution/ast/uncovered_code.rb +88 -0
  20. data/lib/evilution/ast/value_object_definition.rb +25 -0
  21. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  22. data/lib/evilution/baseline/report.rb +66 -0
  23. data/lib/evilution/baseline/spec_failure.rb +38 -0
  24. data/lib/evilution/baseline.rb +67 -30
  25. data/lib/evilution/cli/parser/file_args.rb +2 -1
  26. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  27. data/lib/evilution/cli.rb +3 -2
  28. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  29. data/lib/evilution/config.rb +3 -2
  30. data/lib/evilution/equivalent/detector.rb +3 -1
  31. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  32. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  33. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  34. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  35. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  36. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  38. data/lib/evilution/example_filter.rb +45 -0
  39. data/lib/evilution/hooks/registry.rb +2 -1
  40. data/lib/evilution/integration/base.rb +2 -0
  41. data/lib/evilution/integration/known_failures.rb +26 -0
  42. data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
  43. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  44. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  45. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  46. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  47. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  48. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  49. data/lib/evilution/integration/minitest.rb +45 -6
  50. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  51. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  52. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  53. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  54. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  55. data/lib/evilution/integration/rspec.rb +22 -4
  56. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  57. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  58. data/lib/evilution/integration/test_unit.rb +37 -9
  59. data/lib/evilution/isolation/fork.rb +2 -1
  60. data/lib/evilution/isolation/in_process.rb +21 -4
  61. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  62. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  63. data/lib/evilution/memory/leak_check.rb +27 -3
  64. data/lib/evilution/mutation.rb +7 -2
  65. data/lib/evilution/mutator/base.rb +49 -5
  66. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  67. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  68. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  69. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  70. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  71. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  72. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  73. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  74. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  75. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  76. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  77. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  78. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  79. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  80. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  81. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  82. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  83. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  84. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  85. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  86. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  87. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  88. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  89. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  90. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  91. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  92. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  93. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  94. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  95. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  96. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  97. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  98. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  99. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  100. data/lib/evilution/mutator/registry.rb +31 -2
  101. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  102. data/lib/evilution/process_supervisor.rb +3 -2
  103. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  104. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  105. data/lib/evilution/reporter/json/baseline.rb +15 -0
  106. data/lib/evilution/reporter/json.rb +7 -2
  107. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  108. data/lib/evilution/result/mutation_result.rb +9 -1
  109. data/lib/evilution/result/summary.rb +42 -2
  110. data/lib/evilution/runner/baseline_runner.rb +9 -4
  111. data/lib/evilution/runner/canary.rb +2 -57
  112. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  113. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  114. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  115. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  116. data/lib/evilution/runner/mutation_executor.rb +1 -1
  117. data/lib/evilution/runner/mutation_planner.rb +11 -2
  118. data/lib/evilution/runner/report_publisher.rb +3 -2
  119. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  120. data/lib/evilution/runner.rb +4 -1
  121. data/lib/evilution/subject.rb +8 -2
  122. data/lib/evilution/version.rb +1 -1
  123. data/lib/evilution.rb +28 -0
  124. data/script/memory_check +62 -16
  125. metadata +83 -4
data/docs/architecture.md CHANGED
@@ -12,7 +12,10 @@ For deeper dives on two subsystems that have their own docs, see
12
12
  ## The one-paragraph version
13
13
 
14
14
  Evilution parses each target file with [Prism](https://github.com/ruby/prism)
15
- into an AST with exact byte offsets. Every method becomes a *subject*. Each
15
+ into an AST with exact byte offsets. Every method becomes a *subject*, and so
16
+ does every value-object definition outside a method (`Point = Data.define(:x)`)
17
+ and every scope declared with a literal body (`scope :recent, -> { ... }`),
18
+ and every guard or callback written out inside an AASM `event` or `state`. Each
16
19
  mutation *operator* walks a subject's AST and emits *mutations* — byte-range
17
20
  edits applied by source-level surgery (no AST unparsing). For every mutation,
18
21
  evilution copies the file, applies the edit, runs the covering specs in an
@@ -48,7 +51,7 @@ Everything lives under `lib/evilution/`.
48
51
  | `Config`, `Config::*` | Merge `.evilution.yml` + CLI flags + env, validate, freeze. | `config.rb`, `config/sources.rb`, `config/validators/*` |
49
52
  | `Runner`, `Runner::*` | Orchestrate the whole run. Each stage is its own collaborator. | `runner.rb`, `runner/*` |
50
53
  | `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::*` | 111 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
54
+ | `Mutator`, `Mutator::Operator::*` | 136 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
52
55
  | `Mutation` | An immutable mutation record (original/mutated sources, slice, location, parse status). | `mutation.rb` |
53
56
  | `SpecResolver`, `SpecSelector` | Map a source file to its covering spec files (layout heuristics + explicit mappings). | `spec_resolver.rb`, `spec_selector.rb` |
54
57
  | `Isolation::{Fork,InProcess}`, `ProcessSupervisor` | Run one mutation's tests in isolation; process-group lifecycle, sandboxing, TERM/KILL ladder. | `isolation/fork.rb`, `process_supervisor.rb` |
@@ -105,13 +108,34 @@ class that owns it.
105
108
  2. **Subjects** — `Runner::SubjectPipeline#call` resolves target files (explicit,
106
109
  `source:<glob>`, or `Git::ChangedFiles`), then `AST::Parser#call` runs
107
110
  `Prism.parse` and `AST::SubjectFinder` (a `Prism::Visitor`) emits one
108
- `Evilution::Subject` per `def` node. Optional descendant/target/line-range filters follow.
111
+ `Evilution::Subject` per `def` node (kind `:method`), plus one per
112
+ `Data.define` / `Struct.new` assigned to a constant or used as a superclass
113
+ outside any method (kind `:constant`, named after the constant), plus one per
114
+ `scope :name, -> { }` / `lambda { }` / `proc { }` written directly in a class
115
+ body (kind `:scope`, named `Class.name` after the class method it defines;
116
+ it spans the declaration and its node is the body; a scope declared in a
117
+ concern's `included do ... end` is named after the concern), plus one per literal
118
+ callable inside an `event` or `state` of a class-body `aasm do ... end` --
119
+ keyword values (`guard: -> { }`, also inside arrays), those of the event's
120
+ `transitions`, and callback blocks (`before { }`) -- kind `:aasm`, named
121
+ `Class#event` / `Class#state?` after the method the declaration defines, so
122
+ one event may contribute several subjects of the same name (a machine in a
123
+ concern's `included do ... end` is found too, and named after the concern);
124
+ and one per
125
+ literal callable of a class-body callback or validation declaration
126
+ (`validate`, `validates`, `validates_*`, `before_*`, `after_*`, `around_*`)
127
+ -- an `if:` / `unless:` condition, or the callback itself as a block or
128
+ lambda -- kind `:callback`, named as the declaration reads
129
+ (`Order.validate(:credit_limit)`, `Order.before_save`; one in a concern's
130
+ `included do ... end` is named after the concern). Optional
131
+ descendant/target/line-range filters follow.
109
132
  3. **Baseline** — `Runner::BaselineRunner#call` builds the integration from
110
133
  `Runner::INTEGRATIONS` (`rspec`/`minitest`/`test_unit`) and records spec files
111
134
  that already fail *before* any mutation, so their mutations aren't miscounted.
112
135
  An optional `Runner::Canary` proves the pipeline can observe a known mutation.
113
136
  4. **Mutations** — `Runner::MutationPlanner#call` flat-maps subjects through
114
- `Mutator::Registry#mutations_for`. The registry instantiates each operator and
137
+ `Mutator::Registry#mutations_for`. The registry instantiates each operator whose
138
+ `subject_kinds` include the subject's kind (`:method`, `:scope`, `:aasm` and `:callback` by default) and
115
139
  runs `operator.call(subject, filter:)`; each operator subclasses
116
140
  `Mutator::Base` and calls `add_mutation`, which runs `AST::SourceSurgeon` and
117
141
  builds an immutable `Evilution::Mutation`. The planner then **deduplicates**
@@ -142,11 +166,20 @@ class that owns it.
142
166
  `Isolation::Fork#classify_status`: `:timeout` → `:killed` (crash) →
143
167
  `:unresolved` → `:error` → `:survived` (tests passed) → default `:killed`.
144
168
  A `NeutralizationPipeline` can reclassify results into `:neutral` — either
145
- because the covering spec already failed at baseline (`Neutralizer::BaselineFailed`)
146
- or because the test process crashed on infrastructure rather than on the
147
- mutation (`Neutralizer::InfraError`). Each records a `Result::NeutralReason`,
148
- since the two want opposite responses. A survivor is re-run against its whole
149
- spec file before it is reported (`Integration::RSpec#confirm_survivor?`), so a
169
+ because the tests failed on nothing but examples that were already failing
170
+ at baseline (`Neutralizer::BaselineFailed`) or because the test process
171
+ crashed on infrastructure rather than on the mutation
172
+ (`Neutralizer::InfraError`). Each records a `Result::NeutralReason`, since
173
+ the two want opposite responses. For the first, the baseline reports the
174
+ ids of its failing examples (`Integration::RSpec::ExampleIds`, absolute path
175
+ plus position, so they compare across working directories;
176
+ `Integration::Minitest::TestIds`, `Class#test`;
177
+ `Integration::TestUnit::TestIds`, `test(Class)`), the integration is built
178
+ with them (`known_failures:`, held as `Integration::KnownFailures`), and a
179
+ failed run whose failed examples are all among them comes back flagged
180
+ `known_failures_only`; a survivor is never neutralized. A survivor is re-run
181
+ against its whole spec file before it is reported
182
+ (`Integration::RSpec#confirm?`), as is a targeted run flagged that way, so a
150
183
  narrowed example set cannot invent one; after a parallel pass,
151
184
  `MutationExecutor::InfraRetry` re-runs the infrastructure-neutralised
152
185
  mutations serially, once the contention that caused them is gone.
@@ -193,6 +226,53 @@ A mutator is a `Prism::Visitor` subclass that emits byte-range edits.
193
226
  - The operator's registered name is auto-derived from the class name
194
227
  (`MyThing` → `my_thing`); that string is the `operator` field in JSON output
195
228
  and is part of the public contract, so name it deliberately.
229
+ - Operators see method, scope, aasm and callback subjects. One that also applies to value-object
230
+ definitions outside a method overrides `self.subject_kinds` to return
231
+ `%i[method constant]`; a constant subject's node is the definition's
232
+ `CallNode`.
233
+ - A scope subject's mutation only takes effect if the mutated file re-runs
234
+ its `scope` call, which `BodyCallNeutralizer` would otherwise blank: the
235
+ eval source keeps that one call (`keep_offset`). Its `restore_source` is
236
+ the original file with the same call kept; `Integration::Base#call`
237
+ evaluates it after the tests, since in-process runs restore methods only
238
+ by re-evaluating the next mutation's source, where scopes are blanked.
239
+ - A scope in a concern's `included` block has two audiences. The kept
240
+ `included` call registers the mutated block for classes that include the
241
+ concern later (`ConcernStateCleaner` clears the old one first, on apply
242
+ and on restore: a concern ignores a block handed to it twice from the
243
+ same place). For classes that include it already, the eval source guards
244
+ every other call of the block with `ConcernRedeclaration.skipping?` and
245
+ follows the block with `ConcernRedeclaration.call(self)`, which runs the
246
+ block on each of them with the guard up, so only the scope is declared
247
+ again. Both additions go on existing lines; no line number moves.
248
+ - A callback subject's declaration is kept too, wrapped in
249
+ `CallbackRedeclaration.call(self, __FILE__, lines) { ... }`. Callbacks do
250
+ not replace on re-declaration: a symbol callback moves to the end of its
251
+ chain, a block or validator is added beside the first. The helper reads
252
+ the chains, runs the declaration and rebuilds them through
253
+ `__update_callbacks`, so the new callbacks sit where the ones this
254
+ declaration registered before were. Those are found by the source
255
+ location of their procs the first time (`lines` is the declaration's
256
+ range in the file as loaded) and from the helper's own record afterwards,
257
+ since a mutation may add or remove lines. In a concern's `included` block
258
+ the declaration is wrapped the same way inside the kept block, whose
259
+ other calls are guarded: run on a class that includes the concern already
260
+ it replaces that class's callbacks in place, run by a class including it
261
+ later it simply declares. `BodyCallNeutralizer` leaves a blanked call's
262
+ line breaks behind, so the evaluated source keeps the file's line numbers
263
+ and a class loaded during one mutation can be matched by location in the
264
+ next.
265
+ - An aasm subject works the same way, one level down: the `aasm` call is
266
+ kept, and inside its block every declaration but the `event` or `state`
267
+ holding the mutation is blanked. AASM stores what an event or state holds
268
+ by value, so re-running one replaces it; the machine's own callbacks
269
+ (`after_all_transitions`) accumulate, which is why the rest of the block
270
+ must not run again and why they get no subjects. For a machine inside a
271
+ concern's `included` block the two mechanisms combine: the block is kept
272
+ and followed by `ConcernRedeclaration.call(self)`, and both its other
273
+ calls and the machine's other declarations are guarded with
274
+ `ConcernRedeclaration.skipping?` instead of blanked -- a class that
275
+ includes the concern later needs the whole machine.
196
276
 
197
277
  2. **Require it** in `lib/evilution.rb` alongside the other
198
278
  `require_relative "evilution/mutator/operator/..."` lines.
@@ -66,6 +66,29 @@ after calling `.to_s` on both sides:
66
66
  call{name=log} # matches when node.name.to_s == "log"
67
67
  ```
68
68
 
69
+ **Method names** that end in `?`, `!` or `=` are written as they are, and so are
70
+ operator methods:
71
+
72
+ ```
73
+ call{name=valid?} # record.valid?
74
+ call{name=strip_sources!} # strip_sources!
75
+ call{name=value=} # record.value = 1
76
+ call{name=<=>} # a <=> b
77
+ call{name=-@} # -a (unary minus)
78
+ ```
79
+
80
+ The bare operators are `<=>`, `===`, `==`, `=~`, `[]=`, `[]`, `<=`, `<<`, `<`,
81
+ `>=`, `>>`, `>`, `+@`, `-@`, `+`, `-`, `/`, `%`, `&`, `^` and `~`. Any other
82
+ name — `|`, `!`, `!=`, `!~`, `*`, `**`, a backtick — goes in single or double
83
+ quotes, since unquoted these characters mean alternation, negation or a wildcard.
84
+ A quoted name is matched literally:
85
+
86
+ ```
87
+ call{name='|'} # a | b, not an alternation
88
+ call{name='*'} # a * b, not a wildcard
89
+ call{name='!='} # a != b, not a negation
90
+ ```
91
+
69
92
  **Alternatives** use `|` (OR logic) within a single attribute value:
70
93
 
71
94
  ```
@@ -139,6 +162,13 @@ ignore_patterns:
139
162
 
140
163
  # Suppress mutations on constant `ENV` reads
141
164
  - "call{receiver=constant_read{name=ENV}}"
165
+
166
+ # Suppress mutations on bang and predicate calls, and on `<=>`
167
+ - "call{name=strip_sources!|eager_load!|valid?}"
168
+ - "call{name=<=>}"
169
+
170
+ # Quote names made of reserved characters
171
+ - "call{name='|'}"
142
172
  ```
143
173
 
144
174
  ## Pattern Matching Semantics
@@ -164,12 +194,21 @@ value = "!" value
164
194
  | "*"
165
195
  nested_pattern = node_type "{" attributes "}"
166
196
  alternatives = atom { "|" atom }
167
- atom = identifier | "*"
197
+ atom = name | "*"
198
+ name = identifier [ "?" | "!" | "=" ]
199
+ | operator
200
+ | "'" { any character except "'" } "'"
201
+ | '"' { any character except '"' } '"'
202
+ operator = "<=>" | "===" | "==" | "=~" | "[]=" | "[]" | "<=" | "<<" | "<"
203
+ | ">=" | ">>" | ">" | "+@" | "-@" | "+" | "-" | "/" | "%"
204
+ | "&" | "^" | "~"
168
205
  identifier = [a-zA-Z_] [a-zA-Z0-9_]*
169
206
  ```
170
207
 
171
- A bare `identifier` without `{` is parsed as a scalar value (matched via `.to_s`).
172
- An `identifier` followed by `{` is parsed as a nested pattern.
208
+ A bare `name` without `{` is parsed as a scalar value (matched via `.to_s`).
209
+ An `identifier` followed by `{` is parsed as a nested pattern. Operators are
210
+ read longest first, so `<=>` is never taken for `<=`. Node types and attribute
211
+ names are always plain identifiers. A quoted name is at least one character long.
173
212
 
174
213
  ## Examples
175
214
 
@@ -183,6 +222,9 @@ An `identifier` followed by `{` is parsed as a nested pattern.
183
222
  | `call{receiver=**}` | `foo()`, `x.foo()`, `a.b.foo()` | *(matches all)* |
184
223
  | `def{name=to_s}` | `def to_s; ... end` | `def to_str; ... end` |
185
224
  | `call{name=!log}` | `debug()`, `info()` | `log()` |
225
+ | `call{name=valid?\|save!}` | `x.valid?`, `save!` | `valid`, `save` |
226
+ | `call{name=<=>}` | `a <=> b` | `a <= b` |
227
+ | `call{name='\|'}` | `a \| b` | `a & b` |
186
228
 
187
229
  ## Design Decisions
188
230
 
@@ -191,8 +233,11 @@ An `identifier` followed by `{` is parsed as a nested pattern.
191
233
  write patterns immediately.
192
234
 
193
235
  2. **Unquoted values**: Attribute values don't require quotes. This keeps YAML clean
194
- and avoids escaping issues. The tradeoff is that values cannot contain `{`, `}`,
195
- `,`, `=`, `|`, or `!` — these are reserved syntax characters.
236
+ and avoids escaping issues. Method names ending in `?`, `!` or `=` and operators
237
+ that cannot be confused with pattern syntax are written bare too. Only names
238
+ built from reserved characters (`|`, `!`, `*`) or characters outside a method
239
+ name need quotes; there is no escaping inside them. In YAML, use one kind of
240
+ quote for the YAML string and the other inside it: `- "call{name='|'}"`.
196
241
 
197
242
  3. **Implicit wildcards for unspecified attributes**: `call{name=log}` matches
198
243
  regardless of receiver, arguments, etc. Only specified attributes constrain the
data/docs/isolation.md CHANGED
@@ -22,8 +22,8 @@ transaction block with a half-delivered exception. The mask is
22
22
  [load-bearing for transaction correctness][rails-handle-interrupt].
23
23
 
24
24
  That mask interacts badly with `Timeout.timeout`. The Timeout gem schedules a
25
- timer thread which fires `Thread#raise Timeout::Error` at the main thread when
26
- the deadline hits. Ruby receives the `raise`, inspects the interrupt mask, sees
25
+ timer thread which fires `Thread#raise` at the main thread when the deadline
26
+ hits. Ruby receives the `raise`, inspects the interrupt mask, sees
27
27
  `Exception => :never`, and **queues the exception for later delivery** — "later"
28
28
  meaning "when the masked block exits". If the mutant is stuck in an infinite
29
29
  loop inside the transaction, the block never exits, the queued exception is
@@ -57,6 +57,26 @@ The same hazard applies to any Ruby code that uses
57
57
  blocks that wrap "must complete" sections. If your target code can touch any
58
58
  of those, prefer `--isolation fork`.
59
59
 
60
+ ## How `in_process` ends a run that is out of time
61
+
62
+ The timeout raises `Evilution::Isolation::InProcess::Expired` into the run. It
63
+ is a `SignalException`, not a `Timeout::Error`, because it has to end the
64
+ whole test run. A test framework rescues nearly everything a test raises:
65
+ handed a `Timeout::Error`, it records one failed test and moves on to the
66
+ next, which may reach the same endless loop with no timer left to stop it.
67
+ Minitest, Test::Unit and RSpec all let a `SignalException` through, so the run
68
+ stops at once and the mutation is reported as timed out rather than killed.
69
+
70
+ It is not an `Interrupt` either. Tooling that handles Ctrl-C — maxitest, for
71
+ one — rescues `Interrupt` and skips every test from then on, which would turn
72
+ each later mutation in the process into a survivor.
73
+
74
+ One raise is all there is. Code under test, or a test, that rescues
75
+ `Exception` or `SignalException` and then loops again cannot be stopped from
76
+ inside its own process; use `--isolation fork` for it. The same goes for a
77
+ mutant that crashes the Ruby VM: under `fork` that costs one child, under
78
+ `in_process` the whole run.
79
+
60
80
  ## Parent-process preload
61
81
 
62
82
  Fork isolation's one downside is that every child pays the cost of loading
@@ -132,6 +152,23 @@ the MCP server is a long-lived process that handles runs from different
132
152
  projects — preloading one project's Rails stack into a shared process would
133
153
  poison subsequent runs.
134
154
 
155
+ ## Test files under `in_process` (Minitest, Test::Unit)
156
+
157
+ Under `in_process` one process runs every mutation, and Minitest and
158
+ Test::Unit learn of a test class only when it is first defined: loading its
159
+ file a second time reopens the class and registers nothing. Each test file is
160
+ therefore loaded once per process, and the classes it registered are
161
+ dispatched again for every later mutation
162
+ (`Integration::Loading::TestClassCache`).
163
+
164
+ The consequence is that code in the body of a test file runs once. Tests
165
+ generated at load time from the code under test (`OPS.each { |op|
166
+ define_method("test_#{op}") { … } }`) keep the set built on the first load,
167
+ and state held on a test class carries over from one mutation to the next.
168
+ Under `fork` every child loads the file itself, so use `--isolation fork` for
169
+ a suite that depends on either. RSpec is unaffected: it is reset and its spec
170
+ files are loaded again for each mutation.
171
+
135
172
  ## Sandboxed working directory
136
173
 
137
174
  Every isolator runs `test_command.call` inside a per-mutation scratch
@@ -142,7 +142,7 @@ Key-by-key:
142
142
  | `matcher.ignore` | `ignore_patterns` / `# evilution:disable` | AST patterns or inline comments |
143
143
  | `fail_fast` | `fail_fast` | integer N or `null` |
144
144
  | `--score` gate (CLI; default `1.0`) | `min_score` | evilution defaults to `0.0` — set your own gate |
145
- | _(n/a)_ | `profile: strict` | opt into aggressive truthiness mutators |
145
+ | _(n/a)_ | `profile: strict` | opt into aggressive mutators (truthiness, swallowed errors, statement order) |
146
146
 
147
147
  Run `evilution init` for a fully commented template, and point your editor at
148
148
  `schema/evilution.config.schema.json` for autocomplete/validation.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+ require_relative "literal_callable"
6
+
7
+ # The guards and callbacks of an AASM state machine that are written out as
8
+ # literal callables:
9
+ #
10
+ # aasm do
11
+ # state :paid, before_exit: -> { ... }
12
+ # event :ship, guard: -> { ... } do
13
+ # before { ... }
14
+ # transitions from: :paid, to: :shipped, guard: -> { ... }
15
+ # end
16
+ # end
17
+ #
18
+ # Each belongs to the `event` or `state` declaration it is written in. AASM
19
+ # keeps what a declaration holds by value, so running that one declaration
20
+ # again replaces it, and a mutated body takes effect. The machine's own
21
+ # callbacks (`after_all_transitions`) accumulate instead, and are left out.
22
+ #
23
+ # Only a receiver-less `aasm` call with a block, written directly in a class
24
+ # body, counts: that is the call the mutated file re-runs.
25
+ module Evilution::AST::AasmDeclaration
26
+ # body: the node holding the callable's body. declaration: its `event` or
27
+ # `state` call. method_name: the instance method that declaration defines.
28
+ Callable = Data.define(:body, :declaration, :method_name)
29
+
30
+ DECLARATIONS = %i[event state].freeze
31
+ EVENT_CALLBACKS = %i[
32
+ before before_transaction before_success success after after_transaction after_commit error ensure
33
+ ].freeze
34
+ private_constant :DECLARATIONS, :EVENT_CALLBACKS
35
+
36
+ # The callables of every machine among a class body's direct statements.
37
+ def self.in_body(body)
38
+ statements_of(body).flat_map { |node| machine_callables(node) }
39
+ end
40
+
41
+ # The block of an `aasm` call, or nil when node is not one.
42
+ def self.machine_block(node)
43
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :aasm && node.receiver.nil?
44
+
45
+ node.block if node.block.is_a?(Prism::BlockNode)
46
+ end
47
+
48
+ # The name specs call for whatever sits on a line of a machine: the event,
49
+ # or the first state, declared there. nil outside every declaration.
50
+ def self.token_at(machine, line)
51
+ declaration = declarations_of(machine).find do |node|
52
+ line.between?(node.location.start_line, node.location.end_line)
53
+ end
54
+ declaration && declared_name(declaration)
55
+ end
56
+
57
+ def self.machine_callables(machine)
58
+ declarations_of(machine).flat_map do |declaration|
59
+ bodies_of(declaration).map do |body|
60
+ Callable.new(body: body, declaration: declaration, method_name: method_name(declaration))
61
+ end
62
+ end
63
+ end
64
+
65
+ def self.declarations_of(machine)
66
+ block = machine_block(machine)
67
+ return [] unless block
68
+
69
+ statements_of(block.body).select { |node| declaration?(node) }
70
+ end
71
+
72
+ def self.declaration?(node)
73
+ bare_call?(node) && DECLARATIONS.include?(node.name) && arguments_of(node).first.is_a?(Prism::SymbolNode)
74
+ end
75
+
76
+ def self.declared_name(declaration)
77
+ arguments_of(declaration).first.unescaped
78
+ end
79
+
80
+ # `event :ship` defines `ship`; `state :paid` defines `paid?`.
81
+ def self.method_name(declaration)
82
+ declaration.name == :event ? declared_name(declaration) : "#{declared_name(declaration)}?"
83
+ end
84
+
85
+ def self.bodies_of(declaration)
86
+ keyword_bodies(declaration) + (declaration.name == :event ? event_block_bodies(declaration) : [])
87
+ end
88
+
89
+ # What an event's block holds: its transitions, with keyword callables of
90
+ # their own, and callbacks given as blocks (`before { }`).
91
+ def self.event_block_bodies(event)
92
+ return [] unless event.block.is_a?(Prism::BlockNode)
93
+
94
+ statements_of(event.block.body).select { |node| bare_call?(node) }.flat_map do |node|
95
+ if node.name == :transitions then keyword_bodies(node)
96
+ elsif callback_block?(node) then [node.block]
97
+ else []
98
+ end
99
+ end
100
+ end
101
+
102
+ def self.callback_block?(node)
103
+ EVENT_CALLBACKS.include?(node.name) && node.arguments.nil? && node.block.is_a?(Prism::BlockNode)
104
+ end
105
+
106
+ # `guard: -> { }` and `guards: [-> { }, :named]` alike.
107
+ def self.keyword_bodies(call)
108
+ keywords = arguments_of(call).last
109
+ return [] unless keywords.is_a?(Prism::KeywordHashNode)
110
+
111
+ keywords.elements.grep(Prism::AssocNode).flat_map do |pair|
112
+ values = pair.value.is_a?(Prism::ArrayNode) ? pair.value.elements : [pair.value]
113
+ values.filter_map { |value| Evilution::AST::LiteralCallable.body_of(value) }
114
+ end
115
+ end
116
+
117
+ def self.bare_call?(node)
118
+ node.is_a?(Prism::CallNode) && node.receiver.nil?
119
+ end
120
+
121
+ def self.arguments_of(call)
122
+ call.arguments ? call.arguments.arguments : []
123
+ end
124
+
125
+ def self.statements_of(body)
126
+ body = body.statements if body.is_a?(Prism::BeginNode)
127
+ body.is_a?(Prism::StatementsNode) ? body.body : []
128
+ end
129
+
130
+ private_class_method :machine_callables, :declarations_of, :declaration?, :declared_name, :method_name,
131
+ :bodies_of, :event_block_bodies, :callback_block?, :keyword_bodies, :bare_call?,
132
+ :arguments_of, :statements_of
133
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+ require_relative "literal_callable"
6
+
7
+ # The callables written out in a callback or validation declaration of a
8
+ # class body:
9
+ #
10
+ # validate :credit_limit, if: -> { paid? && total.positive? }
11
+ # validates :title, presence: true, unless: [-> { draft? }, :imported?]
12
+ # before_save { self.slug = title.parameterize }
13
+ # after_commit -> { notify }, on: :create
14
+ #
15
+ # That is the condition given to `if:` / `unless:`, and the callback itself
16
+ # when it is a block or a lambda. A condition or callback named by a symbol
17
+ # is a method, and a subject of its own already.
18
+ #
19
+ # Declarations are recognised by name -- `validate`, `validates`,
20
+ # `validates_*`, `before_*`, `after_*`, `around_*` -- and only receiver-less,
21
+ # directly in a class body: that is the call the mutated file re-runs.
22
+ module Evilution::AST::CallbackDeclaration
23
+ # body: the node holding the callable's body. declaration: the call it is
24
+ # written in. label: how the declaration reads, for naming the subject.
25
+ Callable = Data.define(:body, :declaration, :label)
26
+
27
+ NAME = /\A(?:validate|validates|validates_\w+|(?:before|after|around)_\w+)\z/
28
+ CONDITIONS = %w[if unless].freeze
29
+ private_constant :NAME, :CONDITIONS
30
+
31
+ # The callables of every callback declaration among a class body's direct
32
+ # statements.
33
+ def self.in_body(body)
34
+ statements = body.is_a?(Prism::BeginNode) ? body.statements : body
35
+ return [] unless statements.is_a?(Prism::StatementsNode)
36
+
37
+ statements.body.select { |node| match?(node) }.flat_map { |declaration| callables(declaration) }
38
+ end
39
+
40
+ # Whether node is a callback declaration that can be run again on its own.
41
+ # One holding a heredoc cannot be wrapped on its own lines, and is left out.
42
+ def self.match?(node)
43
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && NAME.match?(node.name.to_s) && !heredoc?(node)
44
+ end
45
+
46
+ def self.callables(declaration)
47
+ label = label_of(declaration)
48
+ bodies_of(declaration).map { |body| Callable.new(body: body, declaration: declaration, label: label) }
49
+ end
50
+
51
+ # `validate :credit_limit, if: ...` reads as `validate(:credit_limit)`; a
52
+ # declaration naming nothing, as its own name.
53
+ def self.label_of(declaration)
54
+ named = arguments_of(declaration).find { |argument| argument.is_a?(Prism::SymbolNode) }
55
+ named ? "#{declaration.name}(:#{named.unescaped})" : declaration.name.to_s
56
+ end
57
+
58
+ def self.bodies_of(declaration)
59
+ arguments = arguments_of(declaration)
60
+ bodies = arguments.filter_map { |argument| Evilution::AST::LiteralCallable.body_of(argument) }
61
+ bodies.concat(condition_bodies(arguments.last))
62
+ bodies << declaration.block if declaration.block.is_a?(Prism::BlockNode)
63
+ bodies
64
+ end
65
+
66
+ # `if: -> { }` and `unless: [-> { }, :named]` alike.
67
+ def self.condition_bodies(keywords)
68
+ return [] unless keywords.is_a?(Prism::KeywordHashNode)
69
+
70
+ keywords.elements.select { |pair| condition?(pair) }.flat_map do |pair|
71
+ values = pair.value.is_a?(Prism::ArrayNode) ? pair.value.elements : [pair.value]
72
+ values.filter_map { |value| Evilution::AST::LiteralCallable.body_of(value) }
73
+ end
74
+ end
75
+
76
+ def self.condition?(pair)
77
+ pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) && CONDITIONS.include?(pair.key.unescaped)
78
+ end
79
+
80
+ def self.arguments_of(call)
81
+ call.arguments ? call.arguments.arguments : []
82
+ end
83
+
84
+ def self.heredoc?(node)
85
+ return true if node.respond_to?(:heredoc?) && node.heredoc?
86
+
87
+ node.compact_child_nodes.any? { |child| heredoc?(child) }
88
+ end
89
+
90
+ private_class_method :callables, :label_of, :bodies_of, :condition_bodies, :condition?, :arguments_of, :heredoc?
91
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # The block of an `ActiveSupport::Concern`'s `included do ... end`: code
7
+ # written in a module body that runs in the body of each class including the
8
+ # module. Only the receiver-less, argument-less form with a literal block
9
+ # counts -- `def self.included(base)` and `included(base) { }` are plain Ruby
10
+ # hooks with rules of their own.
11
+ module Evilution::AST::IncludedBlock
12
+ # The block node, or nil when node is not an `included` block call.
13
+ def self.of(node)
14
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :included
15
+ return nil unless node.receiver.nil? && node.arguments.nil?
16
+
17
+ node.block if node.block.is_a?(Prism::BlockNode)
18
+ end
19
+
20
+ # The included blocks among a module body's direct statements.
21
+ def self.in_body(body)
22
+ statements = body.is_a?(Prism::BeginNode) ? body.statements : body
23
+ return [] unless statements.is_a?(Prism::StatementsNode)
24
+
25
+ statements.body.filter_map { |node| of(node) }
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # A callable written out where it is used: `-> { }`, `lambda { }` or
7
+ # `proc { }`. Its body is what mutations go into. A constant, a variable or
8
+ # `method(:name)` in the same place has no body of its own to mutate.
9
+ module Evilution::AST::LiteralCallable
10
+ BLOCK_CALLS = %i[lambda proc].freeze
11
+ private_constant :BLOCK_CALLS
12
+
13
+ # The node holding the callable's body -- a LambdaNode, or the BlockNode of
14
+ # a `lambda` / `proc` call -- or nil when node is not a literal callable.
15
+ def self.body_of(node)
16
+ return node if node.is_a?(Prism::LambdaNode)
17
+ return nil unless node.is_a?(Prism::CallNode) && node.receiver.nil? && BLOCK_CALLS.include?(node.name)
18
+
19
+ node.block if node.block.is_a?(Prism::BlockNode)
20
+ end
21
+ end