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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +62 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +77 -0
- data/README.md +80 -24
- data/docs/architecture.md +89 -9
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +39 -2
- data/docs/migration-from-mutant.md +1 -1
- data/lib/evilution/ast/aasm_declaration.rb +133 -0
- data/lib/evilution/ast/callback_declaration.rb +91 -0
- data/lib/evilution/ast/included_block.rb +27 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/parser.rb +119 -16
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +14 -15
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/uncovered_code.rb +88 -0
- data/lib/evilution/ast/value_object_definition.rb +25 -0
- data/lib/evilution/baseline/failure_formatter.rb +27 -0
- data/lib/evilution/baseline/report.rb +66 -0
- data/lib/evilution/baseline/spec_failure.rb +38 -0
- data/lib/evilution/baseline.rb +67 -30
- data/lib/evilution/cli/parser/file_args.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/cli.rb +3 -2
- data/lib/evilution/config/validators/spec_mappings.rb +2 -1
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
- data/lib/evilution/example_filter.rb +45 -0
- data/lib/evilution/hooks/registry.rb +2 -1
- data/lib/evilution/integration/base.rb +2 -0
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
- data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
- data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
- data/lib/evilution/integration/minitest/test_ids.rb +21 -0
- data/lib/evilution/integration/minitest.rb +45 -6
- data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
- data/lib/evilution/integration/rspec/example_ids.rb +35 -0
- data/lib/evilution/integration/rspec/result_builder.rb +6 -1
- data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
- data/lib/evilution/integration/rspec/state_guard.rb +4 -1
- data/lib/evilution/integration/rspec.rb +22 -4
- data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
- data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
- data/lib/evilution/integration/test_unit.rb +37 -9
- data/lib/evilution/isolation/fork.rb +2 -1
- data/lib/evilution/isolation/in_process.rb +21 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
- data/lib/evilution/memory/leak_check.rb +27 -3
- data/lib/evilution/mutation.rb +7 -2
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
- data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
- data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
- data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
- data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
- data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
- data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
- data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
- data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
- data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
- data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
- data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
- data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
- data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
- data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
- data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
- data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
- data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/registry.rb +31 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json/baseline.rb +15 -0
- data/lib/evilution/reporter/json.rb +7 -2
- data/lib/evilution/result/baseline_neutralization.rb +10 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +9 -4
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
- data/lib/evilution/runner/mutation_executor.rb +1 -1
- data/lib/evilution/runner/mutation_planner.rb +11 -2
- data/lib/evilution/runner/report_publisher.rb +3 -2
- data/lib/evilution/runner/subject_pipeline.rb +46 -1
- data/lib/evilution/runner.rb +4 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +28 -0
- data/script/memory_check +62 -16
- 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
|
|
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::*` |
|
|
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
|
|
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
|
|
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
|
|
146
|
-
or because the test process
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
data/docs/ast_pattern_syntax.md
CHANGED
|
@@ -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 =
|
|
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 `
|
|
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.
|
|
195
|
-
|
|
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
|
|
26
|
-
|
|
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
|
|
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
|