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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +70 -0
  3. data/CHANGELOG.md +50 -0
  4. data/README.md +64 -22
  5. data/docs/architecture.md +56 -12
  6. data/docs/isolation.md +82 -0
  7. data/lib/evilution/ast/class_body.rb +101 -0
  8. data/lib/evilution/ast/included_block.rb +20 -6
  9. data/lib/evilution/ast/never_nil.rb +36 -0
  10. data/lib/evilution/ast/parser.rb +94 -31
  11. data/lib/evilution/ast/round_half_mode.rb +38 -0
  12. data/lib/evilution/ast/source_surgeon.rb +2 -3
  13. data/lib/evilution/ast/syntax_check.rb +56 -0
  14. data/lib/evilution/ast/uncovered_code.rb +7 -7
  15. data/lib/evilution/cli/exit_guard.rb +26 -4
  16. data/lib/evilution/integration/base.rb +10 -2
  17. data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
  18. data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
  19. data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
  20. data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
  21. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  22. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  23. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  24. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  25. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  26. data/lib/evilution/isolation/fork.rb +82 -10
  27. data/lib/evilution/isolation/in_process.rb +79 -4
  28. data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
  29. data/lib/evilution/mcp/info_tool.rb +3 -2
  30. data/lib/evilution/mutation.rb +18 -11
  31. data/lib/evilution/mutator/base.rb +27 -7
  32. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  33. data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
  34. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  35. data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
  36. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  37. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  38. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  39. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  40. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  41. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  42. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  43. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  44. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  45. data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
  46. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  47. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  48. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  49. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  50. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  51. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  52. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  53. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  54. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  55. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  56. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  57. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  58. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  59. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  60. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  61. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  62. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  63. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  64. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  65. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  66. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  67. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  68. data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
  69. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  70. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  71. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  72. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  73. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  74. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  75. data/lib/evilution/mutator/primitives.rb +24 -2
  76. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  77. data/lib/evilution/mutator/registry.rb +29 -2
  78. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  79. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  80. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  81. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  82. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  83. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  84. data/lib/evilution/parallel/work_queue.rb +15 -9
  85. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  86. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  87. data/lib/evilution/runner/subject_pipeline.rb +20 -51
  88. data/lib/evilution/runner.rb +1 -1
  89. data/lib/evilution/subject.rb +18 -1
  90. data/lib/evilution/version.rb +1 -1
  91. data/lib/evilution.rb +62 -0
  92. 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
- 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
+ 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::*` | 136 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `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` is named after the concern), plus one per literal
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 named after the concern);
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). Optional
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`. The planner then **deduplicates**
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; `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.
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
- return nil unless node.is_a?(Prism::CallNode) && node.name == :included
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.in_body(body)
22
- statements = body.is_a?(Prism::BeginNode) ? body.statements : body
23
- return [] unless statements.is_a?(Prism::StatementsNode)
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
- statements.body.filter_map { |node| of(node) }
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
@@ -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
- @context.push(constant_name(node.constant_path))
53
- IncludedBlock.in_body(node.body).each do |block|
54
- add_scope_subjects(block.body)
55
- add_aasm_subjects(block.body)
56
- add_callback_subjects(block.body)
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
- @context.push(constant_name(node.constant_path))
67
- superclass = node.superclass
68
- add_subject(superclass, @context.join("::"), :constant) if ValueObjectDefinition.match?(superclass)
69
- add_scope_subjects(node.body)
70
- add_aasm_subjects(node.body)
71
- add_callback_subjects(node.body)
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
- constant = constant_expression?(node.expression)
81
- @context.push(constant_name(node.expression)) if constant
82
- within_scope(singleton: true) { super }
83
- @context.pop if constant
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 unless ValueObjectDefinition.match?(node.value)
96
+ return within_definition(node.value, node.name.to_s) { super } if ValueObjectDefinition.match?(node.value)
94
97
 
95
- within_definition(node.value, node.name.to_s) { super }
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
- return super unless ValueObjectDefinition.match?(node.value)
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
- within_definition(node.value, path_name(node.target)) { super }
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
- @context.push(name)
122
- add_subject(definition, @context.join("::"), :constant)
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
- @context.pop
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 method on every
130
- # class including the concern, and is named after the concern.
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 = Prism.parse(mutated).success? ? :ok : :unparseable
21
+ status = Evilution::AST::SyntaxCheck.valid?(mutated) ? :ok : :unparseable
23
22
  Result.new(source: mutated, status: status).freeze
24
23
  end
25
24
  end