evilution 1.3.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 +132 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +127 -0
- data/README.md +136 -38
- data/docs/architecture.md +135 -11
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +121 -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/class_body.rb +101 -0
- data/lib/evilution/ast/included_block.rb +41 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/never_nil.rb +36 -0
- data/lib/evilution/ast/parser.rb +187 -21
- 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/round_half_mode.rb +38 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -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 +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/exit_guard.rb +26 -4
- 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 +11 -1
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
- data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
- 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/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 +84 -11
- data/lib/evilution/isolation/in_process.rb +100 -8
- data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
- data/lib/evilution/mcp/info_tool.rb +3 -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 +21 -9
- data/lib/evilution/mutator/base.rb +69 -5
- data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
- data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +10 -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/comparison_operand_swap.rb +54 -0
- 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 +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- 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/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- 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_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
- 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/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/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/operator_write_receiver_promotion.rb +38 -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/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/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_class_widening.rb +54 -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/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/send_mutation.rb +2 -0
- 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 +41 -24
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- 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 +58 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- 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/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/strategy/parallel.rb +10 -0
- 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/target.rb +86 -0
- data/lib/evilution/runner/subject_pipeline.rb +57 -43
- data/lib/evilution/runner.rb +5 -2
- data/lib/evilution/subject.rb +25 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +90 -0
- data/script/memory_check +62 -16
- metadata +113 -4
data/docs/architecture.md
CHANGED
|
@@ -12,7 +12,13 @@ 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
|
+
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
|
|
16
22
|
mutation *operator* walks a subject's AST and emits *mutations* — byte-range
|
|
17
23
|
edits applied by source-level surgery (no AST unparsing). For every mutation,
|
|
18
24
|
evilution copies the file, applies the edit, runs the covering specs in an
|
|
@@ -48,7 +54,7 @@ Everything lives under `lib/evilution/`.
|
|
|
48
54
|
| `Config`, `Config::*` | Merge `.evilution.yml` + CLI flags + env, validate, freeze. | `config.rb`, `config/sources.rb`, `config/validators/*` |
|
|
49
55
|
| `Runner`, `Runner::*` | Orchestrate the whole run. Each stage is its own collaborator. | `runner.rb`, `runner/*` |
|
|
50
56
|
| `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::*` |
|
|
57
|
+
| `Mutator`, `Mutator::Operator::*` | 155 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
|
|
52
58
|
| `Mutation` | An immutable mutation record (original/mutated sources, slice, location, parse status). | `mutation.rb` |
|
|
53
59
|
| `SpecResolver`, `SpecSelector` | Map a source file to its covering spec files (layout heuristics + explicit mappings). | `spec_resolver.rb`, `spec_selector.rb` |
|
|
54
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` |
|
|
@@ -105,16 +111,43 @@ class that owns it.
|
|
|
105
111
|
2. **Subjects** — `Runner::SubjectPipeline#call` resolves target files (explicit,
|
|
106
112
|
`source:<glob>`, or `Git::ChangedFiles`), then `AST::Parser#call` runs
|
|
107
113
|
`Prism.parse` and `AST::SubjectFinder` (a `Prism::Visitor`) emits one
|
|
108
|
-
`Evilution::Subject` per `def` node
|
|
114
|
+
`Evilution::Subject` per `def` node (kind `:method`), plus one per
|
|
115
|
+
`Data.define` / `Struct.new` assigned to a constant or used as a superclass
|
|
116
|
+
outside any method (kind `:constant`, named after the constant), plus one per
|
|
117
|
+
`scope :name, -> { }` / `lambda { }` / `proc { }` written directly in a class
|
|
118
|
+
body (kind `:scope`, named `Class.name` after the class method it defines;
|
|
119
|
+
it spans the declaration and its node is the body; a scope declared in a
|
|
120
|
+
concern's `included do ... end` or `prepended do ... end` is named after the
|
|
121
|
+
concern), plus one per literal
|
|
122
|
+
callable inside an `event` or `state` of a class-body `aasm do ... end` --
|
|
123
|
+
keyword values (`guard: -> { }`, also inside arrays), those of the event's
|
|
124
|
+
`transitions`, and callback blocks (`before { }`) -- kind `:aasm`, named
|
|
125
|
+
`Class#event` / `Class#state?` after the method the declaration defines, so
|
|
126
|
+
one event may contribute several subjects of the same name (a machine in a
|
|
127
|
+
concern's `included do ... end` or `prepended do ... end` is found too, and
|
|
128
|
+
named after the concern);
|
|
129
|
+
and one per
|
|
130
|
+
literal callable of a class-body callback or validation declaration
|
|
131
|
+
(`validate`, `validates`, `validates_*`, `before_*`, `after_*`, `around_*`)
|
|
132
|
+
-- an `if:` / `unless:` condition, or the callback itself as a block or
|
|
133
|
+
lambda -- kind `:callback`, named as the declaration reads
|
|
134
|
+
(`Order.validate(:credit_limit)`, `Order.before_save`; one in a concern's
|
|
135
|
+
`included do ... end` or `prepended do ... end` is named after the concern).
|
|
136
|
+
Optional
|
|
137
|
+
descendant/target/line-range filters follow.
|
|
109
138
|
3. **Baseline** — `Runner::BaselineRunner#call` builds the integration from
|
|
110
139
|
`Runner::INTEGRATIONS` (`rspec`/`minitest`/`test_unit`) and records spec files
|
|
111
140
|
that already fail *before* any mutation, so their mutations aren't miscounted.
|
|
112
141
|
An optional `Runner::Canary` proves the pipeline can observe a known mutation.
|
|
113
142
|
4. **Mutations** — `Runner::MutationPlanner#call` flat-maps subjects through
|
|
114
|
-
`Mutator::Registry#mutations_for`. The registry instantiates each operator
|
|
143
|
+
`Mutator::Registry#mutations_for`. The registry instantiates each operator whose
|
|
144
|
+
`subject_kinds` include the subject's kind (`:method`, `:scope`, `:aasm` and `:callback` by default) and
|
|
115
145
|
runs `operator.call(subject, filter:)`; each operator subclasses
|
|
116
146
|
`Mutator::Base` and calls `add_mutation`, which runs `AST::SourceSurgeon` and
|
|
117
|
-
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**
|
|
118
151
|
(by `file_path` + mutated source), and filters **disabled** (`DisableComment`),
|
|
119
152
|
**Sorbet `sig`** (`AST::SorbetSigDetector`), and **equivalent**
|
|
120
153
|
(`Equivalent::Detector`) mutations. Equivalent ones become `:equivalent`
|
|
@@ -141,12 +174,25 @@ class that owns it.
|
|
|
141
174
|
guard / unresolved); the final symbol is chosen by
|
|
142
175
|
`Isolation::Fork#classify_status`: `:timeout` → `:killed` (crash) →
|
|
143
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`.
|
|
144
181
|
A `NeutralizationPipeline` can reclassify results into `:neutral` — either
|
|
145
|
-
because the
|
|
146
|
-
or because the test process
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
182
|
+
because the tests failed on nothing but examples that were already failing
|
|
183
|
+
at baseline (`Neutralizer::BaselineFailed`) or because the test process
|
|
184
|
+
crashed on infrastructure rather than on the mutation
|
|
185
|
+
(`Neutralizer::InfraError`). Each records a `Result::NeutralReason`, since
|
|
186
|
+
the two want opposite responses. For the first, the baseline reports the
|
|
187
|
+
ids of its failing examples (`Integration::RSpec::ExampleIds`, absolute path
|
|
188
|
+
plus position, so they compare across working directories;
|
|
189
|
+
`Integration::Minitest::TestIds`, `Class#test`;
|
|
190
|
+
`Integration::TestUnit::TestIds`, `test(Class)`), the integration is built
|
|
191
|
+
with them (`known_failures:`, held as `Integration::KnownFailures`), and a
|
|
192
|
+
failed run whose failed examples are all among them comes back flagged
|
|
193
|
+
`known_failures_only`; a survivor is never neutralized. A survivor is re-run
|
|
194
|
+
against its whole spec file before it is reported
|
|
195
|
+
(`Integration::RSpec#confirm?`), as is a targeted run flagged that way, so a
|
|
150
196
|
narrowed example set cannot invent one; after a parallel pass,
|
|
151
197
|
`MutationExecutor::InfraRetry` re-runs the infrastructure-neutralised
|
|
152
198
|
mutations serially, once the contention that caused them is gone.
|
|
@@ -158,7 +204,9 @@ class that owns it.
|
|
|
158
204
|
the score. `Commands::Run` maps `summary.success?(min_score:)` to exit code
|
|
159
205
|
`0`/`1` (`2` on error), and `CLI::ExitGuard` — installed by the executable before
|
|
160
206
|
anything can preload the project — has the final word on the process status, so a
|
|
161
|
-
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`.
|
|
162
210
|
|
|
163
211
|
## How to add a new mutator
|
|
164
212
|
|
|
@@ -193,6 +241,82 @@ A mutator is a `Prism::Visitor` subclass that emits byte-range edits.
|
|
|
193
241
|
- The operator's registered name is auto-derived from the class name
|
|
194
242
|
(`MyThing` → `my_thing`); that string is the `operator` field in JSON output
|
|
195
243
|
and is part of the public contract, so name it deliberately.
|
|
244
|
+
- Operators see method, scope, aasm and callback subjects. One that also applies to value-object
|
|
245
|
+
definitions outside a method overrides `self.subject_kinds` to return
|
|
246
|
+
`%i[method constant]`; a constant subject's node is the definition's
|
|
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.
|
|
259
|
+
- A scope subject's mutation only takes effect if the mutated file re-runs
|
|
260
|
+
its `scope` call, which `BodyCallNeutralizer` would otherwise blank: the
|
|
261
|
+
eval source keeps that one call (`keep_offset`). Its `restore_source` is
|
|
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`).
|
|
270
|
+
- A scope in a concern's `included` block has two audiences. The kept
|
|
271
|
+
`included` call registers the mutated block for classes that include the
|
|
272
|
+
concern later (`ConcernStateCleaner` clears the old one first, on apply
|
|
273
|
+
and on restore: a concern ignores a block handed to it twice from the
|
|
274
|
+
same place). For classes that include it already, the eval source guards
|
|
275
|
+
every other call of the block with `ConcernRedeclaration.skipping?` and
|
|
276
|
+
follows the block with `ConcernRedeclaration.call(self)`, which runs the
|
|
277
|
+
block on each of them with the guard up, so only the scope is declared
|
|
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).
|
|
292
|
+
- A callback subject's declaration is kept too, wrapped in
|
|
293
|
+
`CallbackRedeclaration.call(self, __FILE__, lines) { ... }`. Callbacks do
|
|
294
|
+
not replace on re-declaration: a symbol callback moves to the end of its
|
|
295
|
+
chain, a block or validator is added beside the first. The helper reads
|
|
296
|
+
the chains, runs the declaration and rebuilds them through
|
|
297
|
+
`__update_callbacks`, so the new callbacks sit where the ones this
|
|
298
|
+
declaration registered before were. Those are found by the source
|
|
299
|
+
location of their procs the first time (`lines` is the declaration's
|
|
300
|
+
range in the file as loaded) and from the helper's own record afterwards,
|
|
301
|
+
since a mutation may add or remove lines. In a concern's `included` block
|
|
302
|
+
the declaration is wrapped the same way inside the kept block, whose
|
|
303
|
+
other calls are guarded: run on a class that includes the concern already
|
|
304
|
+
it replaces that class's callbacks in place, run by a class including it
|
|
305
|
+
later it simply declares. `BodyCallNeutralizer` leaves a blanked call's
|
|
306
|
+
line breaks behind, so the evaluated source keeps the file's line numbers
|
|
307
|
+
and a class loaded during one mutation can be matched by location in the
|
|
308
|
+
next.
|
|
309
|
+
- An aasm subject works the same way, one level down: the `aasm` call is
|
|
310
|
+
kept, and inside its block every declaration but the `event` or `state`
|
|
311
|
+
holding the mutation is blanked. AASM stores what an event or state holds
|
|
312
|
+
by value, so re-running one replaces it; the machine's own callbacks
|
|
313
|
+
(`after_all_transitions`) accumulate, which is why the rest of the block
|
|
314
|
+
must not run again and why they get no subjects. For a machine inside a
|
|
315
|
+
concern's `included` block the two mechanisms combine: the block is kept
|
|
316
|
+
and followed by `ConcernRedeclaration.call(self)`, and both its other
|
|
317
|
+
calls and the machine's other declarations are guarded with
|
|
318
|
+
`ConcernRedeclaration.skipping?` instead of blanked -- a class that
|
|
319
|
+
includes the concern later needs the whole machine.
|
|
196
320
|
|
|
197
321
|
2. **Require it** in `lib/evilution.rb` alongside the other
|
|
198
322
|
`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,99 @@ 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
|
+
|
|
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
|
+
|
|
60
153
|
## Parent-process preload
|
|
61
154
|
|
|
62
155
|
Fork isolation's one downside is that every child pays the cost of loading
|
|
@@ -132,6 +225,23 @@ the MCP server is a long-lived process that handles runs from different
|
|
|
132
225
|
projects — preloading one project's Rails stack into a shared process would
|
|
133
226
|
poison subsequent runs.
|
|
134
227
|
|
|
228
|
+
## Test files under `in_process` (Minitest, Test::Unit)
|
|
229
|
+
|
|
230
|
+
Under `in_process` one process runs every mutation, and Minitest and
|
|
231
|
+
Test::Unit learn of a test class only when it is first defined: loading its
|
|
232
|
+
file a second time reopens the class and registers nothing. Each test file is
|
|
233
|
+
therefore loaded once per process, and the classes it registered are
|
|
234
|
+
dispatched again for every later mutation
|
|
235
|
+
(`Integration::Loading::TestClassCache`).
|
|
236
|
+
|
|
237
|
+
The consequence is that code in the body of a test file runs once. Tests
|
|
238
|
+
generated at load time from the code under test (`OPS.each { |op|
|
|
239
|
+
define_method("test_#{op}") { … } }`) keep the set built on the first load,
|
|
240
|
+
and state held on a test class carries over from one mutation to the next.
|
|
241
|
+
Under `fork` every child loads the file itself, so use `--isolation fork` for
|
|
242
|
+
a suite that depends on either. RSpec is unaffected: it is reset and its spec
|
|
243
|
+
files are loaded again for each mutation.
|
|
244
|
+
|
|
135
245
|
## Sandboxed working directory
|
|
136
246
|
|
|
137
247
|
Every isolator runs `test_command.call` inside a per-mutation scratch
|
|
@@ -169,6 +279,15 @@ single in-flight mutation is recorded as `:timeout` / `:error`, and the run
|
|
|
169
279
|
continues with the remaining work. A single pathological mutation costs you one
|
|
170
280
|
result, not the entire run.
|
|
171
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
|
+
|
|
172
291
|
Each worker is also its own process-group leader, so killing a worker reaps the
|
|
173
292
|
mutation subprocess and any grandchildren it spawned rather than orphaning them,
|
|
174
293
|
and a terminal interrupt (Ctrl-C) is forwarded to every worker group — an
|
|
@@ -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
|