evilution 1.4.0 → 1.5.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 +70 -0
- data/CHANGELOG.md +50 -0
- data/README.md +64 -22
- data/docs/architecture.md +56 -12
- data/docs/isolation.md +82 -0
- data/lib/evilution/ast/class_body.rb +101 -0
- data/lib/evilution/ast/included_block.rb +20 -6
- data/lib/evilution/ast/never_nil.rb +36 -0
- data/lib/evilution/ast/parser.rb +94 -31
- data/lib/evilution/ast/round_half_mode.rb +38 -0
- data/lib/evilution/ast/source_surgeon.rb +2 -3
- data/lib/evilution/ast/syntax_check.rb +56 -0
- data/lib/evilution/ast/uncovered_code.rb +7 -7
- data/lib/evilution/cli/exit_guard.rb +26 -4
- data/lib/evilution/integration/base.rb +10 -2
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
- data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
- data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
- data/lib/evilution/integration/loading/own_methods.rb +87 -0
- data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
- data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
- data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
- data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
- data/lib/evilution/isolation/fork.rb +82 -10
- data/lib/evilution/isolation/in_process.rb +79 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
- data/lib/evilution/mcp/info_tool.rb +3 -2
- data/lib/evilution/mutation.rb +18 -11
- data/lib/evilution/mutator/base.rb +27 -7
- data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
- data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
- data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
- data/lib/evilution/mutator/operator/array_literal.rb +29 -0
- data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
- data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
- data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
- data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
- data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
- data/lib/evilution/mutator/operator/float_literal.rb +54 -19
- data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
- data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
- data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
- data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
- data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
- data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
- data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
- data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
- data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
- data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
- data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
- data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
- data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
- data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
- data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
- data/lib/evilution/mutator/operator/string_literal.rb +29 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
- data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
- data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
- data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
- data/lib/evilution/mutator/primitives.rb +24 -2
- data/lib/evilution/mutator/receiver_positions.rb +79 -0
- data/lib/evilution/mutator/registry.rb +29 -2
- data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
- data/lib/evilution/parallel/work_queue/channel.rb +58 -5
- data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
- data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
- data/lib/evilution/parallel/work_queue/worker.rb +13 -4
- data/lib/evilution/parallel/work_queue.rb +15 -9
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
- data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
- data/lib/evilution/runner/subject_pipeline.rb +20 -51
- data/lib/evilution/runner.rb +1 -1
- data/lib/evilution/subject.rb +18 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +62 -0
- metadata +32 -2
data/docs/architecture.md
CHANGED
|
@@ -13,9 +13,12 @@ For deeper dives on two subsystems that have their own docs, see
|
|
|
13
13
|
|
|
14
14
|
Evilution parses each target file with [Prism](https://github.com/ruby/prism)
|
|
15
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
|
-
|
|
18
|
-
|
|
16
|
+
does every value-object definition outside a method (`Point = Data.define(:x)`),
|
|
17
|
+
every other constant assignment outside a method (`LIMIT = 10`), every scope
|
|
18
|
+
declared with a literal body (`scope :recent, -> { ... }`), every guard or
|
|
19
|
+
callback written out inside an AASM `event` or `state`, and the conditions and
|
|
20
|
+
bodies of callback and validation declarations — in a class body or in a
|
|
21
|
+
concern's `included` / `prepended` block. Each
|
|
19
22
|
mutation *operator* walks a subject's AST and emits *mutations* — byte-range
|
|
20
23
|
edits applied by source-level surgery (no AST unparsing). For every mutation,
|
|
21
24
|
evilution copies the file, applies the edit, runs the covering specs in an
|
|
@@ -51,7 +54,7 @@ Everything lives under `lib/evilution/`.
|
|
|
51
54
|
| `Config`, `Config::*` | Merge `.evilution.yml` + CLI flags + env, validate, freeze. | `config.rb`, `config/sources.rb`, `config/validators/*` |
|
|
52
55
|
| `Runner`, `Runner::*` | Orchestrate the whole run. Each stage is its own collaborator. | `runner.rb`, `runner/*` |
|
|
53
56
|
| `AST`, `Subject` | Prism parse, find method subjects, source surgery, pattern matching, heredoc spans. | `ast/parser.rb`, `ast/source_surgeon.rb`, `subject.rb` |
|
|
54
|
-
| `Mutator`, `Mutator::Operator::*` |
|
|
57
|
+
| `Mutator`, `Mutator::Operator::*` | 155 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
|
|
55
58
|
| `Mutation` | An immutable mutation record (original/mutated sources, slice, location, parse status). | `mutation.rb` |
|
|
56
59
|
| `SpecResolver`, `SpecSelector` | Map a source file to its covering spec files (layout heuristics + explicit mappings). | `spec_resolver.rb`, `spec_selector.rb` |
|
|
57
60
|
| `Isolation::{Fork,InProcess}`, `ProcessSupervisor` | Run one mutation's tests in isolation; process-group lifecycle, sandboxing, TERM/KILL ladder. | `isolation/fork.rb`, `process_supervisor.rb` |
|
|
@@ -114,20 +117,23 @@ class that owns it.
|
|
|
114
117
|
`scope :name, -> { }` / `lambda { }` / `proc { }` written directly in a class
|
|
115
118
|
body (kind `:scope`, named `Class.name` after the class method it defines;
|
|
116
119
|
it spans the declaration and its node is the body; a scope declared in a
|
|
117
|
-
concern's `included do ... end`
|
|
120
|
+
concern's `included do ... end` or `prepended do ... end` is named after the
|
|
121
|
+
concern), plus one per literal
|
|
118
122
|
callable inside an `event` or `state` of a class-body `aasm do ... end` --
|
|
119
123
|
keyword values (`guard: -> { }`, also inside arrays), those of the event's
|
|
120
124
|
`transitions`, and callback blocks (`before { }`) -- kind `:aasm`, named
|
|
121
125
|
`Class#event` / `Class#state?` after the method the declaration defines, so
|
|
122
126
|
one event may contribute several subjects of the same name (a machine in a
|
|
123
|
-
concern's `included do ... end` is found too, and
|
|
127
|
+
concern's `included do ... end` or `prepended do ... end` is found too, and
|
|
128
|
+
named after the concern);
|
|
124
129
|
and one per
|
|
125
130
|
literal callable of a class-body callback or validation declaration
|
|
126
131
|
(`validate`, `validates`, `validates_*`, `before_*`, `after_*`, `around_*`)
|
|
127
132
|
-- an `if:` / `unless:` condition, or the callback itself as a block or
|
|
128
133
|
lambda -- kind `:callback`, named as the declaration reads
|
|
129
134
|
(`Order.validate(:credit_limit)`, `Order.before_save`; one in a concern's
|
|
130
|
-
`included do ... end` is named after the concern).
|
|
135
|
+
`included do ... end` or `prepended do ... end` is named after the concern).
|
|
136
|
+
Optional
|
|
131
137
|
descendant/target/line-range filters follow.
|
|
132
138
|
3. **Baseline** — `Runner::BaselineRunner#call` builds the integration from
|
|
133
139
|
`Runner::INTEGRATIONS` (`rspec`/`minitest`/`test_unit`) and records spec files
|
|
@@ -138,7 +144,10 @@ class that owns it.
|
|
|
138
144
|
`subject_kinds` include the subject's kind (`:method`, `:scope`, `:aasm` and `:callback` by default) and
|
|
139
145
|
runs `operator.call(subject, filter:)`; each operator subclasses
|
|
140
146
|
`Mutator::Base` and calls `add_mutation`, which runs `AST::SourceSurgeon` and
|
|
141
|
-
builds an immutable `Evilution::Mutation`.
|
|
147
|
+
builds an immutable `Evilution::Mutation`. Whether the mutated source parses
|
|
148
|
+
is `AST::SyntaxCheck`'s call: Prism first and, on an interpreter that
|
|
149
|
+
compiles with parse.y (Ruby 3.3, or `--parser=parse.y`), parse.y as well,
|
|
150
|
+
since the two disagree on a few constructs. The planner then **deduplicates**
|
|
142
151
|
(by `file_path` + mutated source), and filters **disabled** (`DisableComment`),
|
|
143
152
|
**Sorbet `sig`** (`AST::SorbetSigDetector`), and **equivalent**
|
|
144
153
|
(`Equivalent::Detector`) mutations. Equivalent ones become `:equivalent`
|
|
@@ -165,6 +174,10 @@ class that owns it.
|
|
|
165
174
|
guard / unresolved); the final symbol is chosen by
|
|
166
175
|
`Isolation::Fork#classify_status`: `:timeout` → `:killed` (crash) →
|
|
167
176
|
`:unresolved` → `:error` → `:survived` (tests passed) → default `:killed`.
|
|
177
|
+
A crash is a test that raised, or a mutated source that raised while it was
|
|
178
|
+
loaded and whose original still loads (`MutationApplier` marks it
|
|
179
|
+
`load_crashed` after evaluating the original); a load failure the original
|
|
180
|
+
shares stays an `:error`.
|
|
168
181
|
A `NeutralizationPipeline` can reclassify results into `:neutral` — either
|
|
169
182
|
because the tests failed on nothing but examples that were already failing
|
|
170
183
|
at baseline (`Neutralizer::BaselineFailed`) or because the test process
|
|
@@ -191,7 +204,9 @@ class that owns it.
|
|
|
191
204
|
the score. `Commands::Run` maps `summary.success?(min_score:)` to exit code
|
|
192
205
|
`0`/`1` (`2` on error), and `CLI::ExitGuard` — installed by the executable before
|
|
193
206
|
anything can preload the project — has the final word on the process status, so a
|
|
194
|
-
preloaded spec helper's at-exit hook cannot replace it.
|
|
207
|
+
preloaded spec helper's at-exit hook cannot replace it. The guard also ends a run
|
|
208
|
+
that a mutation took down under in-process isolation with `2`, where Ruby would
|
|
209
|
+
end it with `1`.
|
|
195
210
|
|
|
196
211
|
## How to add a new mutator
|
|
197
212
|
|
|
@@ -230,12 +245,28 @@ A mutator is a `Prism::Visitor` subclass that emits byte-range edits.
|
|
|
230
245
|
definitions outside a method overrides `self.subject_kinds` to return
|
|
231
246
|
`%i[method constant]`; a constant subject's node is the definition's
|
|
232
247
|
`CallNode`.
|
|
248
|
+
- Every other assignment to a constant outside a method (`LIMIT = 10`) is a
|
|
249
|
+
`:constant_write` subject, whose node is the whole assignment.
|
|
250
|
+
An operator write (`LIMIT += 1`) is one too, under the same name.
|
|
251
|
+
`ConstantWriteToNil` takes no other kind and nils the value of a plain
|
|
252
|
+
assignment only. `CompoundAssignment` handles the operator writes, and
|
|
253
|
+
with the literal operators extends `Mutator::ConstantValueSubjects` to
|
|
254
|
+
take constant-write subjects alongside the default kinds. `SubjectPipeline` drops subjects of a kind no registered operator
|
|
255
|
+
accepts (`Registry#accepts?`). Re-evaluating the mutated file assigns the constant again
|
|
256
|
+
and the restore source puts it back; what read the constant while
|
|
257
|
+
the file first loaded (a class-body call, a constant in another file
|
|
258
|
+
computed from it) keeps the old value.
|
|
233
259
|
- A scope subject's mutation only takes effect if the mutated file re-runs
|
|
234
260
|
its `scope` call, which `BodyCallNeutralizer` would otherwise blank: the
|
|
235
261
|
eval source keeps that one call (`keep_offset`). Its `restore_source` is
|
|
236
|
-
the original file with the same call kept
|
|
237
|
-
|
|
238
|
-
|
|
262
|
+
the original file with the same call kept.
|
|
263
|
+
- Every mutation carries a `restore_source`, which `Integration::Base#call`
|
|
264
|
+
evaluates after the tests: the original file, class-body calls
|
|
265
|
+
neutralized. An in-process run would otherwise leave the last mutation
|
|
266
|
+
of a file applied while the next file is mutated, and that file's tests
|
|
267
|
+
would fail on its behalf. For a subject with no declaration to keep it
|
|
268
|
+
is one string per file, built once and shared
|
|
269
|
+
(`Mutator::Base.original_restore_source`).
|
|
239
270
|
- A scope in a concern's `included` block has two audiences. The kept
|
|
240
271
|
`included` call registers the mutated block for classes that include the
|
|
241
272
|
concern later (`ConcernStateCleaner` clears the old one first, on apply
|
|
@@ -245,6 +276,19 @@ A mutator is a `Prism::Visitor` subclass that emits byte-range edits.
|
|
|
245
276
|
follows the block with `ConcernRedeclaration.call(self)`, which runs the
|
|
246
277
|
block on each of them with the guard up, so only the scope is declared
|
|
247
278
|
again. Both additions go on existing lines; no line number moves.
|
|
279
|
+
- A method defined in a concern's `included` or `prepended` block, or a
|
|
280
|
+
constant assigned there, is handled the same way: the parser marks every
|
|
281
|
+
subject written in such a block (`Subject#concern_hook?`), and
|
|
282
|
+
`Mutator::Base` keeps the block for it as it does for a scope. The other
|
|
283
|
+
methods of the block and the constants it assigns are guarded like its
|
|
284
|
+
calls, so only the mutated one is defined again and no constant loses
|
|
285
|
+
what it has gathered. For a `prepended` block the eval source ends it with
|
|
286
|
+
`ConcernRedeclaration.call(self, :prepended)`, which runs it on the
|
|
287
|
+
classes that prepend the concern (it comes before them in their
|
|
288
|
+
ancestors) rather than on those that include it. A method the class has
|
|
289
|
+
defined itself over one of the block's is put back afterwards
|
|
290
|
+
(`Integration::Loading::OwnMethods`, which tells the two apart by the
|
|
291
|
+
file they were written in).
|
|
248
292
|
- A callback subject's declaration is kept too, wrapped in
|
|
249
293
|
`CallbackRedeclaration.call(self, __FILE__, lines) { ... }`. Callbacks do
|
|
250
294
|
not replace on re-declaration: a symbol callback moves to the end of its
|
data/docs/isolation.md
CHANGED
|
@@ -77,6 +77,79 @@ inside its own process; use `--isolation fork` for it. The same goes for a
|
|
|
77
77
|
mutant that crashes the Ruby VM: under `fork` that costs one child, under
|
|
78
78
|
`in_process` the whole run.
|
|
79
79
|
|
|
80
|
+
## What `in_process` cannot contain
|
|
81
|
+
|
|
82
|
+
A mutant runs in the same Ruby process as evilution, so whatever takes that
|
|
83
|
+
process down takes the run with it.
|
|
84
|
+
|
|
85
|
+
An exception outside `StandardError` that leaves the test run is contained:
|
|
86
|
+
a `SystemStackError` from a mutant that recurses without end is the usual
|
|
87
|
+
one. It is reported for that mutation and the run goes on. The mutation is
|
|
88
|
+
`killed` when the exception left the tests after the mutated source loaded,
|
|
89
|
+
as a crashing test would be, and an `error` when it was raised earlier.
|
|
90
|
+
|
|
91
|
+
One kind of stack overflow is not contained. When the recursion goes through
|
|
92
|
+
`const_missing` — a mutant of a `const_missing` that is installed on `Object`
|
|
93
|
+
does that — Ruby may run neither `rescue` nor `ensure`, or abort outright
|
|
94
|
+
with a `[BUG]` report. Which one depends on the stack limit of the process
|
|
95
|
+
(`ulimit -s`): under the usual 8 MB the machine stack gives out first and the
|
|
96
|
+
process is lost, under a much larger limit the overflow unwinds like any
|
|
97
|
+
other. The other recursions tried (plain method calls, blocks, `send`,
|
|
98
|
+
`method_missing`, `to_s`, a Hash default proc) unwind normally under the
|
|
99
|
+
usual limit, on Ruby 3.4 and 4.0.
|
|
100
|
+
|
|
101
|
+
When the process ends this way without aborting, a line on stderr names the
|
|
102
|
+
mutation:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
[evilution] the run died while testing a mutation under in-process isolation:
|
|
106
|
+
constant_namespace_strip: lib/foo.rb:202 (SystemStackError: stack level too deep).
|
|
107
|
+
In-process isolation cannot contain a mutation that takes the Ruby process down;
|
|
108
|
+
re-run with --isolation fork.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The process then ends with status 2, the status of a tool error: nothing was
|
|
112
|
+
measured, and the line says what to change in the invocation. Ruby's own
|
|
113
|
+
status for an uncaught exception is 1, which would read as a score below
|
|
114
|
+
`--min-score`. The hooks the project registered with `at_exit` still run
|
|
115
|
+
first. Ruby's backtrace of the exception is not printed; the line carries its
|
|
116
|
+
class and message. A run that is interrupted (Ctrl-C) keeps the status Ruby
|
|
117
|
+
gives it.
|
|
118
|
+
|
|
119
|
+
After a `[BUG]` abort there is no such line and no such status: Ruby's own
|
|
120
|
+
crash report is all there is, and the process ends as an abort does (134 from
|
|
121
|
+
a shell), since no `at_exit` hook runs. In both cases no report is written
|
|
122
|
+
and the verdicts collected so far are lost. Re-run with `--isolation fork`,
|
|
123
|
+
where the same mutant costs one child process.
|
|
124
|
+
|
|
125
|
+
## State a file leaves behind when it is evaluated again
|
|
126
|
+
|
|
127
|
+
`in_process` evaluates a source file once for every mutation of it, and once
|
|
128
|
+
more to put the original back. A fork child evaluates it once and ends. A file
|
|
129
|
+
whose evaluation adds to something, instead of replacing it, therefore behaves
|
|
130
|
+
differently under the two.
|
|
131
|
+
|
|
132
|
+
One such case is handled. A file that installs a module it never names,
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
handler = Module.new { def const_missing(name) = ... }
|
|
136
|
+
Object.extend(handler)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
leaves one more module on its host every time it is evaluated, and Ruby has no
|
|
140
|
+
way to take one off. Left alone, the mutation under test would reach the code
|
|
141
|
+
of every earlier mutation through `super`, fail on a predecessor's bug, and be
|
|
142
|
+
reported killed. Evilution remembers the anonymous modules a file's evaluation
|
|
143
|
+
creates and removes their methods when the file is evaluated next, so that
|
|
144
|
+
method lookup passes through them. Only modules are emptied, not anonymous
|
|
145
|
+
classes, and only the methods written in that file.
|
|
146
|
+
|
|
147
|
+
Other things a file can add to at load time are not undone: an `at_exit`
|
|
148
|
+
hook, a `Signal.trap`, a `TracePoint`, a callback pushed into a registry of
|
|
149
|
+
some library. If the file you are mutating does one of those at the top
|
|
150
|
+
level, compare an `in_process` run with a `--isolation fork` run before
|
|
151
|
+
trusting its kills.
|
|
152
|
+
|
|
80
153
|
## Parent-process preload
|
|
81
154
|
|
|
82
155
|
Fork isolation's one downside is that every child pays the cost of loading
|
|
@@ -206,6 +279,15 @@ single in-flight mutation is recorded as `:timeout` / `:error`, and the run
|
|
|
206
279
|
continues with the remaining work. A single pathological mutation costs you one
|
|
207
280
|
result, not the entire run.
|
|
208
281
|
|
|
282
|
+
The same holds for a worker whose reply cannot be trusted. The pipes between
|
|
283
|
+
the parent and a worker are inherited by every process the worker forks, so
|
|
284
|
+
code under test that writes to a descriptor it did not open can put bytes on
|
|
285
|
+
them. A frame that does not decode, that decodes to something other than a
|
|
286
|
+
result of an item the worker holds, or that stops short of the length it
|
|
287
|
+
announced (ten seconds are allowed for the rest) ends that worker: it is
|
|
288
|
+
killed and replaced, and the mutation it held is recorded as an `:error` with
|
|
289
|
+
the message `worker's result could not be read; the worker was replaced`.
|
|
290
|
+
|
|
209
291
|
Each worker is also its own process-group leader, so killing a worker reaps the
|
|
210
292
|
mutation subprocess and any grandchildren it spawned rather than orphaning them,
|
|
211
293
|
and a terminal interrupt (Ctrl-C) is forwarded to every worker group — an
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../ast"
|
|
5
|
+
|
|
6
|
+
# The body of a class, module or `class << self`: the code written outside
|
|
7
|
+
# every method, where no method subject reaches it.
|
|
8
|
+
#
|
|
9
|
+
# Operators that mutate such code attribute their mutants to one method, so
|
|
10
|
+
# each mutant has exactly one subject. That method is the scope's anchor: the
|
|
11
|
+
# first method written in the scope, in source order. Blocks of the body
|
|
12
|
+
# (`included do ... end`) are searched, nested scopes are not: their methods
|
|
13
|
+
# anchor the inner scope. A scope with no method of its own borrows the anchor
|
|
14
|
+
# of the first singleton class in its body that has one, so a class whose
|
|
15
|
+
# methods all sit in `class << self` is still reached.
|
|
16
|
+
class Evilution::AST::ClassBody
|
|
17
|
+
SCOPES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
18
|
+
# Nodes whose children belong to something else: a method, or another scope.
|
|
19
|
+
OPAQUE = [Prism::DefNode, *SCOPES].freeze
|
|
20
|
+
|
|
21
|
+
# The location that closes each kind of conditional or loop.
|
|
22
|
+
CLOSINGS = {
|
|
23
|
+
Prism::IfNode => :end_keyword_loc,
|
|
24
|
+
Prism::UnlessNode => :end_keyword_loc,
|
|
25
|
+
Prism::ElseNode => :end_keyword_loc,
|
|
26
|
+
Prism::WhileNode => :closing_loc,
|
|
27
|
+
Prism::UntilNode => :closing_loc
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :node
|
|
31
|
+
|
|
32
|
+
# The bodies anchored at the method starting on the given line, outermost
|
|
33
|
+
# first. A method anchors its own scope, and with it any enclosing scope
|
|
34
|
+
# that borrows from it.
|
|
35
|
+
def self.anchored_at(tree, line)
|
|
36
|
+
scopes_in(tree).map { |scope| new(scope) }.select { |body| body.anchor_line == line }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.scopes_in(node)
|
|
40
|
+
node.compact_child_nodes.flat_map do |child|
|
|
41
|
+
nested = scopes_in(child)
|
|
42
|
+
SCOPES.include?(child.class) ? [child, *nested] : nested
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method :scopes_in
|
|
47
|
+
|
|
48
|
+
def initialize(node)
|
|
49
|
+
@node = node
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def anchor_line
|
|
53
|
+
first_method = contents.find { |child| child.is_a?(Prism::DefNode) }
|
|
54
|
+
return first_method.location.start_line if first_method
|
|
55
|
+
|
|
56
|
+
singleton_classes = contents.grep(Prism::SingletonClassNode)
|
|
57
|
+
singleton_classes.filter_map { |scope| self.class.new(scope).anchor_line }.first
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The statements of the body the block selects, each one removable: cut
|
|
61
|
+
# out, it leaves code that still parses. Methods and nested scopes are
|
|
62
|
+
# neither offered nor searched, and neither is a statement guarded by a
|
|
63
|
+
# modifier (`include Foo if legacy?`) or an expression used as a value.
|
|
64
|
+
def declarations(&)
|
|
65
|
+
body = @node.body
|
|
66
|
+
return [] unless body
|
|
67
|
+
|
|
68
|
+
statements_in(body, removable: body.is_a?(Prism::StatementsNode)).select(&)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Every node written in the scope, in source order, down to the methods and
|
|
74
|
+
# nested scopes, which are listed but not entered. The scope's own header
|
|
75
|
+
# (its name, its superclass expression) is not part of the body.
|
|
76
|
+
def contents
|
|
77
|
+
@contents ||= @node.body ? descend(@node.body) : []
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def statements_in(node, removable:)
|
|
81
|
+
node.compact_child_nodes.flat_map do |child|
|
|
82
|
+
next [] if OPAQUE.include?(child.class)
|
|
83
|
+
|
|
84
|
+
nested = statements_in(child, removable: child.is_a?(Prism::StatementsNode) && !modifier?(node))
|
|
85
|
+
removable ? [child, *nested] : nested
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A conditional or loop written without its closing keyword: the modifier
|
|
90
|
+
# forms and the ternary, whose statements cannot be cut out.
|
|
91
|
+
def modifier?(node)
|
|
92
|
+
closing = CLOSINGS[node.class]
|
|
93
|
+
!closing.nil? && node.public_send(closing).nil?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def descend(node)
|
|
97
|
+
node.compact_child_nodes.flat_map do |child|
|
|
98
|
+
OPAQUE.include?(child.class) ? [child] : [child, *descend(child)]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -11,17 +11,31 @@ require_relative "../ast"
|
|
|
11
11
|
module Evilution::AST::IncludedBlock
|
|
12
12
|
# The block node, or nil when node is not an `included` block call.
|
|
13
13
|
def self.of(node)
|
|
14
|
-
|
|
14
|
+
hook_block(node, :included)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The block node of a `prepended do ... end`, the same hook for the classes
|
|
18
|
+
# that prepend the module; nil when node is not one.
|
|
19
|
+
def self.prepended(node)
|
|
20
|
+
hook_block(node, :prepended)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.hook_block(node, name)
|
|
24
|
+
return nil unless node.is_a?(Prism::CallNode) && node.name == name
|
|
15
25
|
return nil unless node.receiver.nil? && node.arguments.nil?
|
|
16
26
|
|
|
17
27
|
node.block if node.block.is_a?(Prism::BlockNode)
|
|
18
28
|
end
|
|
29
|
+
private_class_method :hook_block
|
|
19
30
|
|
|
20
|
-
# The included blocks among a module body's direct statements.
|
|
21
|
-
def self.
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
# The included and prepended blocks among a module body's direct statements.
|
|
32
|
+
def self.hooks_in_body(body)
|
|
33
|
+
direct_statements(body).filter_map { |node| of(node) || prepended(node) }
|
|
34
|
+
end
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
def self.direct_statements(body)
|
|
37
|
+
statements = body.is_a?(Prism::BeginNode) ? body.statements : body
|
|
38
|
+
statements.is_a?(Prism::StatementsNode) ? statements.body : []
|
|
26
39
|
end
|
|
40
|
+
private_class_method :direct_statements
|
|
27
41
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../ast"
|
|
5
|
+
|
|
6
|
+
# Expressions whose value is never nil whatever the program does: `self` and
|
|
7
|
+
# the literals other than `nil`. Anything read or computed (a variable, a
|
|
8
|
+
# constant, a call) is not one.
|
|
9
|
+
module Evilution::AST::NeverNil
|
|
10
|
+
NODE_TYPES = [
|
|
11
|
+
Prism::SelfNode,
|
|
12
|
+
Prism::StringNode,
|
|
13
|
+
Prism::InterpolatedStringNode,
|
|
14
|
+
Prism::XStringNode,
|
|
15
|
+
Prism::InterpolatedXStringNode,
|
|
16
|
+
Prism::SymbolNode,
|
|
17
|
+
Prism::InterpolatedSymbolNode,
|
|
18
|
+
Prism::IntegerNode,
|
|
19
|
+
Prism::FloatNode,
|
|
20
|
+
Prism::RationalNode,
|
|
21
|
+
Prism::ImaginaryNode,
|
|
22
|
+
Prism::ArrayNode,
|
|
23
|
+
Prism::HashNode,
|
|
24
|
+
Prism::RangeNode,
|
|
25
|
+
Prism::RegularExpressionNode,
|
|
26
|
+
Prism::InterpolatedRegularExpressionNode,
|
|
27
|
+
Prism::TrueNode,
|
|
28
|
+
Prism::FalseNode,
|
|
29
|
+
Prism::LambdaNode
|
|
30
|
+
].freeze
|
|
31
|
+
private_constant :NODE_TYPES
|
|
32
|
+
|
|
33
|
+
def self.node?(node)
|
|
34
|
+
NODE_TYPES.any? { |type| node.is_a?(type) }
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/evilution/ast/parser.rb
CHANGED
|
@@ -46,41 +46,41 @@ module Evilution::AST
|
|
|
46
46
|
@subjects = []
|
|
47
47
|
@context = []
|
|
48
48
|
@singleton = [false]
|
|
49
|
+
@hooks = []
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
def visit_module_node(node)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
within_context(constant_name(node.constant_path), rooted: rooted?(node.constant_path)) do
|
|
54
|
+
hooks = IncludedBlock.hooks_in_body(node.body)
|
|
55
|
+
within_hooks(hooks) do
|
|
56
|
+
hooks.each { |block| add_declaration_subjects(block.body) }
|
|
57
|
+
within_scope(singleton: false) { super }
|
|
58
|
+
end
|
|
57
59
|
end
|
|
58
|
-
within_scope(singleton: false) { super }
|
|
59
|
-
@context.pop
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# A class whose superclass is a value-object definition
|
|
63
63
|
# (`class Coord < Data.define(:lat, :lng)`) makes a constant subject of
|
|
64
64
|
# that definition, named after the class.
|
|
65
65
|
def visit_class_node(node)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
within_scope(singleton: false) { super }
|
|
73
|
-
@context.pop
|
|
66
|
+
within_context(constant_name(node.constant_path), rooted: rooted?(node.constant_path)) do
|
|
67
|
+
superclass = node.superclass
|
|
68
|
+
add_subject(superclass, @context.join("::"), :constant) if ValueObjectDefinition.match?(superclass)
|
|
69
|
+
add_declaration_subjects(node.body)
|
|
70
|
+
within_scope(singleton: false) { super }
|
|
71
|
+
end
|
|
74
72
|
end
|
|
75
73
|
|
|
76
74
|
# A `def` inside `class << self` defines a method on the enclosing
|
|
77
75
|
# class's singleton, like `def self.name`; inside `class << Store` it
|
|
78
76
|
# defines one on Store.
|
|
79
77
|
def visit_singleton_class_node(node)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
expression = node.expression
|
|
79
|
+
return within_scope(singleton: true) { super } unless constant_expression?(expression)
|
|
80
|
+
|
|
81
|
+
within_context(constant_name(expression), rooted: rooted?(expression)) do
|
|
82
|
+
within_scope(singleton: true) { super }
|
|
83
|
+
end
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def visit_def_node(node)
|
|
@@ -89,16 +89,36 @@ module Evilution::AST
|
|
|
89
89
|
super
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# An assignment to a constant cannot be written inside a method, so it is
|
|
93
|
+
# a subject of its own: the whole assignment, named after the constant. A
|
|
94
|
+
# value-object definition keeps its narrower constant subject instead.
|
|
92
95
|
def visit_constant_write_node(node)
|
|
93
|
-
return super
|
|
96
|
+
return within_definition(node.value, node.name.to_s) { super } if ValueObjectDefinition.match?(node.value)
|
|
94
97
|
|
|
95
|
-
|
|
98
|
+
add_subject(node, scoped_name(node.name), :constant_write)
|
|
99
|
+
super
|
|
96
100
|
end
|
|
97
101
|
|
|
98
102
|
def visit_constant_path_write_node(node)
|
|
99
|
-
|
|
103
|
+
name = path_name(node.target)
|
|
104
|
+
rooted = rooted?(node.target)
|
|
105
|
+
return within_definition(node.value, name, rooted: rooted) { super } if ValueObjectDefinition.match?(node.value)
|
|
106
|
+
|
|
107
|
+
add_subject(node, rooted ? name : scoped_name(name), :constant_write)
|
|
108
|
+
super
|
|
109
|
+
end
|
|
100
110
|
|
|
101
|
-
|
|
111
|
+
# `LIMIT += 1` assigns the constant again and is a subject like the
|
|
112
|
+
# assignment it follows, under the same name.
|
|
113
|
+
def visit_constant_operator_write_node(node)
|
|
114
|
+
add_subject(node, scoped_name(node.name), :constant_write)
|
|
115
|
+
super
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def visit_constant_path_operator_write_node(node)
|
|
119
|
+
name = path_name(node.target)
|
|
120
|
+
add_subject(node, rooted?(node.target) ? name : scoped_name(name), :constant_write)
|
|
121
|
+
super
|
|
102
122
|
end
|
|
103
123
|
|
|
104
124
|
private
|
|
@@ -110,6 +130,30 @@ module Evilution::AST
|
|
|
110
130
|
@singleton.pop
|
|
111
131
|
end
|
|
112
132
|
|
|
133
|
+
# What the block finds inside one of these hook blocks of a concern is
|
|
134
|
+
# marked as written there.
|
|
135
|
+
def within_hooks(blocks)
|
|
136
|
+
enclosing = @hooks
|
|
137
|
+
@hooks = blocks.map { |block| block.location.start_offset...block.location.end_offset }
|
|
138
|
+
yield
|
|
139
|
+
ensure
|
|
140
|
+
@hooks = enclosing
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def in_hook?(location)
|
|
144
|
+
@hooks.any? { |range| range.cover?(location.start_offset) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def scoped_name(name)
|
|
148
|
+
[*@context, name].join("::")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# `::LIMIT` and `::Shared::LIMIT` name a constant from the top level,
|
|
152
|
+
# whatever scope they are written in.
|
|
153
|
+
def rooted?(path)
|
|
154
|
+
path.slice.start_with?("::")
|
|
155
|
+
end
|
|
156
|
+
|
|
113
157
|
def constant_expression?(node)
|
|
114
158
|
node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
115
159
|
end
|
|
@@ -117,17 +161,35 @@ module Evilution::AST
|
|
|
117
161
|
# A value-object definition assigned to a constant is a subject of its
|
|
118
162
|
# own, and a scope for what its block defines: `def area` in
|
|
119
163
|
# `Size = Struct.new(:w, :h) do ... end` is `Size#area`.
|
|
120
|
-
def within_definition(definition, name)
|
|
121
|
-
|
|
122
|
-
|
|
164
|
+
def within_definition(definition, name, rooted: false)
|
|
165
|
+
within_context(name, rooted: rooted) do
|
|
166
|
+
add_subject(definition, @context.join("::"), :constant)
|
|
167
|
+
yield
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Names what the block finds under `name`: within the scope being
|
|
172
|
+
# visited, or from the top level when the name was written rooted.
|
|
173
|
+
def within_context(name, rooted:)
|
|
174
|
+
enclosing = @context
|
|
175
|
+
@context = rooted ? [name] : [*enclosing, name]
|
|
123
176
|
yield
|
|
124
|
-
|
|
177
|
+
ensure
|
|
178
|
+
@context = enclosing
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The callables handed to the declarations of a class body, or of a
|
|
182
|
+
# concern's `included` or `prepended` block.
|
|
183
|
+
def add_declaration_subjects(body)
|
|
184
|
+
add_scope_subjects(body)
|
|
185
|
+
add_aasm_subjects(body)
|
|
186
|
+
add_callback_subjects(body)
|
|
125
187
|
end
|
|
126
188
|
|
|
127
189
|
# `scope :recent, -> { ... }` defines the class method `recent`. The
|
|
128
190
|
# subject spans the whole declaration and mutates its body.
|
|
129
|
-
# Written in a concern's `included` block it defines that
|
|
130
|
-
# class
|
|
191
|
+
# Written in a concern's `included` or `prepended` block it defines that
|
|
192
|
+
# method on every class taking the concern, and is named after the concern.
|
|
131
193
|
def add_scope_subjects(body)
|
|
132
194
|
ScopeDeclaration.in_body(body).each do |declaration|
|
|
133
195
|
name = "#{@context.join("::")}.#{ScopeDeclaration.scope_name(declaration)}"
|
|
@@ -170,7 +232,8 @@ module Evilution::AST
|
|
|
170
232
|
line_number: loc.start_line,
|
|
171
233
|
source: source,
|
|
172
234
|
node: node,
|
|
173
|
-
kind: kind
|
|
235
|
+
kind: kind,
|
|
236
|
+
concern_hook: in_hook?(loc)
|
|
174
237
|
)
|
|
175
238
|
end
|
|
176
239
|
|
|
@@ -186,7 +249,7 @@ module Evilution::AST
|
|
|
186
249
|
# either; like `path_name`, they are named by their own constant.
|
|
187
250
|
def constant_name(node)
|
|
188
251
|
if node.respond_to?(:full_name)
|
|
189
|
-
node.full_name
|
|
252
|
+
node.full_name.delete_prefix("::")
|
|
190
253
|
else
|
|
191
254
|
node.name.to_s
|
|
192
255
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../ast"
|
|
5
|
+
|
|
6
|
+
# The tie-breaking mode of a `round` call: the `:even` of
|
|
7
|
+
# `amount.round(2, half: :even)`. Only a literal mode Ruby accepts counts; a
|
|
8
|
+
# mode held in a variable, `nil` or an unknown symbol is not one.
|
|
9
|
+
module Evilution::AST::RoundHalfMode
|
|
10
|
+
MODES = %i[up even down].freeze
|
|
11
|
+
|
|
12
|
+
# The symbol node holding the mode, or nil when node is not a `round` call
|
|
13
|
+
# with a literal mode.
|
|
14
|
+
def self.of(node)
|
|
15
|
+
return nil unless node.is_a?(Prism::CallNode) && node.name == :round
|
|
16
|
+
|
|
17
|
+
value = half_value(node.arguments)
|
|
18
|
+
value if value.is_a?(Prism::SymbolNode) && MODES.include?(value.unescaped.to_sym)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The value given to `half:`, whatever it is.
|
|
22
|
+
def self.half_value(arguments)
|
|
23
|
+
return nil unless arguments
|
|
24
|
+
|
|
25
|
+
keywords = arguments.arguments.grep(Prism::KeywordHashNode).flat_map(&:elements)
|
|
26
|
+
pair = keywords.find { |element| half_key?(element) }
|
|
27
|
+
pair.value if pair
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.half_key?(element)
|
|
31
|
+
return false unless element.is_a?(Prism::AssocNode)
|
|
32
|
+
|
|
33
|
+
key = element.key
|
|
34
|
+
key.is_a?(Prism::SymbolNode) && key.unescaped == "half"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private_class_method :half_value, :half_key?
|
|
38
|
+
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "prism"
|
|
4
|
-
|
|
5
3
|
require_relative "../ast"
|
|
4
|
+
require_relative "syntax_check"
|
|
6
5
|
|
|
7
6
|
module Evilution::AST::SourceSurgeon
|
|
8
7
|
Result = Struct.new(:source, :status, keyword_init: true) do
|
|
@@ -19,7 +18,7 @@ module Evilution::AST::SourceSurgeon
|
|
|
19
18
|
binary = source.b
|
|
20
19
|
binary[offset, length] = replacement.b
|
|
21
20
|
mutated = binary.force_encoding(source.encoding)
|
|
22
|
-
status =
|
|
21
|
+
status = Evilution::AST::SyntaxCheck.valid?(mutated) ? :ok : :unparseable
|
|
23
22
|
Result.new(source: mutated, status: status).freeze
|
|
24
23
|
end
|
|
25
24
|
end
|