bullematic 0.1.0 → 0.2.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/CHANGELOG.md +18 -1
- data/README.md +23 -11
- data/exe/bullematic +7 -0
- data/lib/bullematic/ast/finder.rb +190 -35
- data/lib/bullematic/ast/parser.rb +1 -17
- data/lib/bullematic/ast/rewriter.rb +90 -28
- data/lib/bullematic/cli.rb +185 -0
- data/lib/bullematic/configuration.rb +15 -4
- data/lib/bullematic/detection.rb +110 -13
- data/lib/bullematic/evidence_store.rb +78 -0
- data/lib/bullematic/fixer.rb +258 -31
- data/lib/bullematic/integrations/minitest.rb +20 -6
- data/lib/bullematic/integrations/rails.rb +23 -9
- data/lib/bullematic/integrations/rspec.rb +19 -7
- data/lib/bullematic/logger.rb +24 -12
- data/lib/bullematic/notifier.rb +131 -36
- data/lib/bullematic/version.rb +1 -1
- data/lib/bullematic.rb +12 -4
- data/sig/generated/bullematic/ast/finder.rbs +70 -11
- data/sig/generated/bullematic/ast/parser.rbs +0 -6
- data/sig/generated/bullematic/ast/rewriter.rbs +28 -11
- data/sig/generated/bullematic/cli.rbs +56 -0
- data/sig/generated/bullematic/configuration.rbs +16 -20
- data/sig/generated/bullematic/detection.rbs +77 -7
- data/sig/generated/bullematic/evidence_store.rbs +35 -0
- data/sig/generated/bullematic/fixer.rbs +59 -3
- data/sig/generated/bullematic/integrations/rails.rbs +7 -0
- data/sig/generated/bullematic/logger.rbs +5 -0
- data/sig/generated/bullematic/notifier.rbs +17 -13
- data/sig/generated/bullematic.rbs +5 -0
- data/sig/stubs/rails.rbs +60 -0
- metadata +161 -8
- data/Rakefile +0 -26
- data/Steepfile +0 -15
- data/assets/logo-header.svg +0 -28
- data/rbs_collection.lock.yaml +0 -24
- data/rbs_collection.yaml +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 491dc6d6f03d8043bbd7799463e7626a821cf22984bd90afcd15526184912214
|
|
4
|
+
data.tar.gz: d968aec38e36c5a90b548dfd654a2917e6faf1ce54d9cf34c3736d1a759a4952
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f49686338aab777ebd78762a8fb1f35729e2ed1d0ee229ed8dc341ffb4a0207ac169ff988783b0c7839ace99c615295fec816f78185f89502dc4981f0b60553b
|
|
7
|
+
data.tar.gz: f9c57f50dbceb5e8fbd26198c0c3549cd26f0c9e15e6beafb59992f788afd68ac138f49f84d20d3b7adcacbc4eed9a8c04facabf55ab0e2bf17fc9246cdf7104
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.2.0] - 2026-08-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Add a `bullematic` executable with explicit `record`, `plan`, `apply`, `verify`, `fix`, and `doctor` commands. Recorded evidence and commands persist across processes, and verification reruns the recorded command to confirm that N+1 warnings are gone.
|
|
13
|
+
- Support high-confidence fixes for direct Active Record queries assigned to local variables, namespaced models, and structurally verified nested associations.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Disable source writes by default: automatic fixing is off, dry-run planning is on, and backups are enabled. Ambiguous, dynamic, or unverifiable query origins and associations are skipped instead of guessed.
|
|
18
|
+
- Record detections without retaining or applying them during normal Rails requests; source changes now require an explicit command or configuration.
|
|
19
|
+
- Support Bullet 6.x through 8.x and report installed dependency compatibility through `bullematic doctor`.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Preserve and deduplicate detections across complete RSpec and Minitest runs, and capture Rails notifications before Bullet clears them.
|
|
24
|
+
- Preserve existing eager-loading arguments, multibyte and legacy source encodings, and line endings while rejecting generated Ruby with invalid syntax.
|
|
25
|
+
- Reject stale, missing, read-only, symlinked, or newly excluded sources and unsafe backup paths, and replace files atomically on Unix and Windows without changing their permissions.
|
|
9
26
|
|
|
10
27
|
## [0.1.0] - 2026-01-13
|
|
11
28
|
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
<p align="center">
|
|
7
7
|
<a href="https://badge.fury.io/rb/bullematic"><img src="https://badge.fury.io/rb/bullematic.svg" alt="Gem Version"></a>
|
|
8
|
-
<a href="https://github.com/ydah/bullematic/actions/workflows/
|
|
8
|
+
<a href="https://github.com/ydah/bullematic/actions/workflows/test.yml"><img src="https://github.com/ydah/bullematic/actions/workflows/test.yml/badge.svg" alt="CI"></a>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
@@ -23,7 +23,7 @@ Bullematic hooks into [Bullet](https://github.com/flyerhzm/bullet) notifications
|
|
|
23
23
|
|
|
24
24
|
## Features
|
|
25
25
|
|
|
26
|
-
-
|
|
26
|
+
- High-confidence N+1 fixes for direct Active Record queries
|
|
27
27
|
- Runtime capture via Bullet notifications
|
|
28
28
|
- AST-based rewrites powered by Prism
|
|
29
29
|
- Dry-run mode and optional backups
|
|
@@ -46,7 +46,7 @@ bundle install
|
|
|
46
46
|
## Requirements
|
|
47
47
|
|
|
48
48
|
- Ruby 3.1+
|
|
49
|
-
- Bullet 6.0
|
|
49
|
+
- Bullet 6.0 through 8.x
|
|
50
50
|
|
|
51
51
|
## Quick Start
|
|
52
52
|
|
|
@@ -61,18 +61,29 @@ Bullematic.configure do |config|
|
|
|
61
61
|
config.auto_fix = true
|
|
62
62
|
config.target_paths = %w[app/controllers app/models app/services]
|
|
63
63
|
config.skip_paths = %w[app/controllers/admin]
|
|
64
|
-
config.dry_run =
|
|
64
|
+
config.dry_run = true # Preview changes before explicitly enabling writes
|
|
65
65
|
config.fix_strategy = :includes # :includes, :preload, or :eager_load
|
|
66
66
|
config.backup = true # Create backup files before modifying
|
|
67
67
|
end
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
After reviewing the planned changes, set `dry_run = false` to apply them. Ambiguous or unverifiable detections are skipped.
|
|
71
|
+
|
|
70
72
|
2. Enable Bullematic at runtime:
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
75
|
BULLEMATIC=1 bundle exec rspec
|
|
74
76
|
```
|
|
75
77
|
|
|
78
|
+
Or use the explicit record/apply workflow:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bundle exec bullematic record -- bundle exec rspec spec/requests/posts_spec.rb
|
|
82
|
+
bundle exec bullematic plan
|
|
83
|
+
bundle exec bullematic apply
|
|
84
|
+
bundle exec bullematic verify
|
|
85
|
+
```
|
|
86
|
+
|
|
76
87
|
## Integrations
|
|
77
88
|
|
|
78
89
|
### RSpec
|
|
@@ -104,17 +115,18 @@ end
|
|
|
104
115
|
### Rails
|
|
105
116
|
|
|
106
117
|
Bullematic auto-loads via Railtie in development and test environments when the gem is loaded.
|
|
118
|
+
The development middleware records detections before Bullet clears its request collector when run through `bullematic record`; it never retains or rewrites detections inside a normal web request.
|
|
107
119
|
|
|
108
120
|
## Configuration
|
|
109
121
|
|
|
110
122
|
| Option | Type | Default | Description |
|
|
111
123
|
|--------|------|---------|-------------|
|
|
112
124
|
| `enabled` | Boolean | `true` | Enable or disable Bullematic |
|
|
113
|
-
| `auto_fix` | Boolean | `
|
|
125
|
+
| `auto_fix` | Boolean | `false` | Automatically apply fixes |
|
|
114
126
|
| `target_paths` | Array | `['app/controllers', 'app/models', 'app/services']` | Paths to consider for fixes |
|
|
115
127
|
| `skip_paths` | Array | `[]` | Paths to skip |
|
|
116
|
-
| `dry_run` | Boolean | `
|
|
117
|
-
| `backup` | Boolean | `
|
|
128
|
+
| `dry_run` | Boolean | `true` | Preview changes without applying |
|
|
129
|
+
| `backup` | Boolean | `true` | Create `.bullematic.bak` backup files |
|
|
118
130
|
| `fix_strategy` | Symbol | `:includes` | Strategy: `:includes`, `:preload`, or `:eager_load` |
|
|
119
131
|
| `logger` | Logger | Auto-configured | Custom logger instance |
|
|
120
132
|
| `debug` | Boolean | `false` | Enable debug mode (raises errors) |
|
|
@@ -124,9 +136,9 @@ Bullematic auto-loads via Railtie in development and test environments when the
|
|
|
124
136
|
1. Bullet detects an N+1 query during request or test execution
|
|
125
137
|
2. Bullematic captures the notification and stack trace
|
|
126
138
|
3. At the end of the run, Bullematic parses the file with Prism
|
|
127
|
-
4. The AST finder
|
|
139
|
+
4. The AST finder requires an exact query location or traces the accessed relation variable to one query
|
|
128
140
|
5. The AST rewriter inserts the appropriate `includes` call
|
|
129
|
-
6. The file is
|
|
141
|
+
6. The file is parsed again and atomically replaced (or logged in dry-run mode)
|
|
130
142
|
|
|
131
143
|
### Example Transformation
|
|
132
144
|
|
|
@@ -152,8 +164,8 @@ end
|
|
|
152
164
|
|
|
153
165
|
## Limitations
|
|
154
166
|
|
|
155
|
-
- Dynamic
|
|
156
|
-
-
|
|
167
|
+
- Dynamic scopes, `send`, metaprogramming, memoized relations, and cross-method/file relation builders are skipped
|
|
168
|
+
- Ambiguous query origins and associations that reflection cannot verify are skipped
|
|
157
169
|
- Already-optimized queries are skipped
|
|
158
170
|
|
|
159
171
|
## Development
|
data/exe/bullematic
ADDED
|
@@ -9,9 +9,9 @@ module Bullematic
|
|
|
9
9
|
QUERY_METHODS = %i[all where find find_by first last order limit offset].freeze #: Array[Symbol]
|
|
10
10
|
|
|
11
11
|
# @rbs!
|
|
12
|
-
# type query_location = { node: untyped, location: untyped, receiver: untyped, method_name: Symbol }
|
|
12
|
+
# type query_location = { node: untyped, location: untyped, receiver: untyped, method_name: Symbol, target_name: Symbol? }
|
|
13
13
|
|
|
14
|
-
QueryLocation = Struct.new(:node, :location, :receiver, :method_name, keyword_init: true)
|
|
14
|
+
QueryLocation = Struct.new(:node, :location, :receiver, :method_name, :target_name, keyword_init: true)
|
|
15
15
|
|
|
16
16
|
# @rbs @parse_result: untyped
|
|
17
17
|
|
|
@@ -34,12 +34,32 @@ module Bullematic
|
|
|
34
34
|
queries
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# @rbs model_class_name: String
|
|
37
38
|
# @rbs line_number: Integer
|
|
38
|
-
# @rbs
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
# @rbs associations: Array[Symbol]
|
|
40
|
+
# @rbs return: Array[QueryLocation]
|
|
41
|
+
def find_model_queries_for_variables_at_line(model_class_name, line_number, associations)
|
|
42
|
+
names = association_receiver_names_at_line(parse_result.value, line_number, associations)
|
|
43
|
+
add_block_receiver_names(parse_result.value, line_number, names)
|
|
44
|
+
scope = method_scope_at_line(parse_result.value, line_number)
|
|
45
|
+
find_model_queries(model_class_name).select do |query|
|
|
46
|
+
query.target_name &&
|
|
47
|
+
names.include?(query.target_name) &&
|
|
48
|
+
!block_parameter_at_line?(parse_result.value, query.target_name, line_number) &&
|
|
49
|
+
query.location.end_line <= line_number &&
|
|
50
|
+
method_scope_at_line(parse_result.value, query.location.start_line).equal?(scope) &&
|
|
51
|
+
!variable_written_between?(scope || parse_result.value, query.target_name, query.location.end_line, line_number, scope)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @rbs parent_association: Symbol
|
|
56
|
+
# @rbs child_associations: Array[Symbol]
|
|
57
|
+
# @rbs line_number: Integer?
|
|
58
|
+
# @rbs return: bool
|
|
59
|
+
def nested_association?(parent_association, child_associations, line_number)
|
|
60
|
+
return false unless line_number
|
|
61
|
+
|
|
62
|
+
nested_association_in_node?(parse_result.value, parent_association, child_associations, line_number)
|
|
43
63
|
end
|
|
44
64
|
|
|
45
65
|
private
|
|
@@ -55,7 +75,7 @@ module Bullematic
|
|
|
55
75
|
return if skip_nodes.include?(node.object_id)
|
|
56
76
|
|
|
57
77
|
case node
|
|
58
|
-
when Prism::InstanceVariableWriteNode
|
|
78
|
+
when Prism::InstanceVariableWriteNode, Prism::LocalVariableWriteNode
|
|
59
79
|
if check_assignment_node(node, queries, model_class_name, target_line)
|
|
60
80
|
mark_descendant_calls(node.value, skip_nodes)
|
|
61
81
|
end
|
|
@@ -81,30 +101,6 @@ module Bullematic
|
|
|
81
101
|
end
|
|
82
102
|
end
|
|
83
103
|
|
|
84
|
-
# @rbs node: untyped
|
|
85
|
-
# @rbs queries: Array[QueryLocation]
|
|
86
|
-
# @rbs target_line: Integer?
|
|
87
|
-
# @rbs return: void
|
|
88
|
-
def visit_all_queries(node, queries, target_line)
|
|
89
|
-
return unless node.respond_to?(:child_nodes)
|
|
90
|
-
|
|
91
|
-
if node.is_a?(Prism::CallNode) && query_method?(node.name)
|
|
92
|
-
node_line = node.location.start_line
|
|
93
|
-
if target_line.nil? || node_line == target_line
|
|
94
|
-
queries << QueryLocation.new(
|
|
95
|
-
node: node,
|
|
96
|
-
location: node.location,
|
|
97
|
-
receiver: find_root_receiver(node),
|
|
98
|
-
method_name: node.name
|
|
99
|
-
)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
node.child_nodes.compact.each do |child|
|
|
104
|
-
visit_all_queries(child, queries, target_line)
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
104
|
# @rbs node: untyped
|
|
109
105
|
# @rbs queries: Array[QueryLocation]
|
|
110
106
|
# @rbs model_class_name: String
|
|
@@ -135,6 +131,7 @@ module Bullematic
|
|
|
135
131
|
|
|
136
132
|
value = node.value
|
|
137
133
|
return false unless value.is_a?(Prism::CallNode)
|
|
134
|
+
return false unless query_method?(value.name)
|
|
138
135
|
|
|
139
136
|
root_receiver = find_root_receiver(value)
|
|
140
137
|
return false unless model_constant?(root_receiver, model_class_name)
|
|
@@ -143,11 +140,101 @@ module Bullematic
|
|
|
143
140
|
node: value,
|
|
144
141
|
location: value.location,
|
|
145
142
|
receiver: root_receiver,
|
|
146
|
-
method_name: value.name
|
|
143
|
+
method_name: value.name,
|
|
144
|
+
target_name: node.name
|
|
147
145
|
)
|
|
148
146
|
true
|
|
149
147
|
end
|
|
150
148
|
|
|
149
|
+
# @rbs node: untyped
|
|
150
|
+
# @rbs line_number: Integer
|
|
151
|
+
# @rbs associations: Array[Symbol]
|
|
152
|
+
# @rbs return: Array[Symbol]
|
|
153
|
+
def association_receiver_names_at_line(node, line_number, associations)
|
|
154
|
+
return [] unless node.respond_to?(:child_nodes)
|
|
155
|
+
|
|
156
|
+
names = [] #: Array[Symbol]
|
|
157
|
+
if node.is_a?(Prism::CallNode) && associations.include?(node.name) && node.location.start_line == line_number
|
|
158
|
+
receiver = find_root_receiver(node)
|
|
159
|
+
if receiver.is_a?(Prism::InstanceVariableReadNode) || receiver.is_a?(Prism::LocalVariableReadNode)
|
|
160
|
+
names << receiver.name
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
node.child_nodes.compact.each do |child|
|
|
164
|
+
names.concat(association_receiver_names_at_line(child, line_number, associations))
|
|
165
|
+
end
|
|
166
|
+
names.uniq
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @rbs node: untyped
|
|
170
|
+
# @rbs line_number: Integer
|
|
171
|
+
# @rbs names: Array[Symbol]
|
|
172
|
+
# @rbs return: void
|
|
173
|
+
def add_block_receiver_names(node, line_number, names)
|
|
174
|
+
return unless node.respond_to?(:child_nodes)
|
|
175
|
+
|
|
176
|
+
if node.is_a?(Prism::CallNode) && node.block &&
|
|
177
|
+
line_number.between?(node.block.location.start_line, node.block.location.end_line)
|
|
178
|
+
parameters = node.block.parameters&.parameters&.requireds || []
|
|
179
|
+
receiver = node.receiver
|
|
180
|
+
if parameters.any? { |parameter| names.include?(parameter.name) } &&
|
|
181
|
+
(receiver.is_a?(Prism::InstanceVariableReadNode) || receiver.is_a?(Prism::LocalVariableReadNode))
|
|
182
|
+
names << receiver.name
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
node.child_nodes.compact.each { |child| add_block_receiver_names(child, line_number, names) }
|
|
187
|
+
names.uniq!
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @rbs node: untyped
|
|
191
|
+
# @rbs name: Symbol
|
|
192
|
+
# @rbs line_number: Integer
|
|
193
|
+
# @rbs return: bool
|
|
194
|
+
def block_parameter_at_line?(node, name, line_number)
|
|
195
|
+
return false unless node.respond_to?(:child_nodes)
|
|
196
|
+
|
|
197
|
+
if node.is_a?(Prism::CallNode) && node.block &&
|
|
198
|
+
line_number.between?(node.block.location.start_line, node.block.location.end_line)
|
|
199
|
+
parameters = node.block.parameters&.parameters&.requireds || []
|
|
200
|
+
return true if parameters.any? { |parameter| parameter.name == name }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
node.child_nodes.compact.any? { |child| block_parameter_at_line?(child, name, line_number) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @rbs node: untyped
|
|
207
|
+
# @rbs line_number: Integer
|
|
208
|
+
# @rbs return: untyped
|
|
209
|
+
def method_scope_at_line(node, line_number)
|
|
210
|
+
return unless node.respond_to?(:child_nodes)
|
|
211
|
+
return unless line_number.between?(node.location.start_line, node.location.end_line)
|
|
212
|
+
|
|
213
|
+
node.child_nodes.compact.each do |child|
|
|
214
|
+
scope = method_scope_at_line(child, line_number)
|
|
215
|
+
return scope if scope
|
|
216
|
+
end
|
|
217
|
+
node if node.is_a?(Prism::DefNode)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @rbs node: untyped
|
|
221
|
+
# @rbs name: Symbol
|
|
222
|
+
# @rbs start_line: Integer
|
|
223
|
+
# @rbs end_line: Integer
|
|
224
|
+
# @rbs scope: untyped
|
|
225
|
+
# @rbs return: bool
|
|
226
|
+
def variable_written_between?(node, name, start_line, end_line, scope)
|
|
227
|
+
return false unless node.respond_to?(:child_nodes)
|
|
228
|
+
return false if node.is_a?(Prism::DefNode) && !node.equal?(scope)
|
|
229
|
+
|
|
230
|
+
written = node.respond_to?(:name) && node.name == name &&
|
|
231
|
+
node.class.name.match?(/\APrism::(?:Instance|Local)Variable(?:And|Operator|Or)?WriteNode\z/) &&
|
|
232
|
+
node.location.start_line > start_line && node.location.start_line <= end_line
|
|
233
|
+
written || node.child_nodes.compact.any? do |child|
|
|
234
|
+
variable_written_between?(child, name, start_line, end_line, scope)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
151
238
|
# @rbs node: untyped
|
|
152
239
|
# @rbs return: untyped
|
|
153
240
|
def find_root_receiver(node)
|
|
@@ -160,9 +247,9 @@ module Bullematic
|
|
|
160
247
|
# @rbs model_class_name: String
|
|
161
248
|
# @rbs return: bool
|
|
162
249
|
def model_constant?(node, model_class_name)
|
|
163
|
-
return false unless node.is_a?(Prism::ConstantReadNode)
|
|
250
|
+
return false unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
164
251
|
|
|
165
|
-
node.
|
|
252
|
+
node.full_name.delete_prefix("::") == model_class_name.delete_prefix("::")
|
|
166
253
|
end
|
|
167
254
|
|
|
168
255
|
# @rbs method_name: Symbol
|
|
@@ -170,6 +257,74 @@ module Bullematic
|
|
|
170
257
|
def query_method?(method_name)
|
|
171
258
|
QUERY_METHODS.include?(method_name)
|
|
172
259
|
end
|
|
260
|
+
|
|
261
|
+
# @rbs node: untyped
|
|
262
|
+
# @rbs parent_association: Symbol
|
|
263
|
+
# @rbs child_associations: Array[Symbol]
|
|
264
|
+
# @rbs line_number: Integer
|
|
265
|
+
# @rbs return: bool
|
|
266
|
+
def nested_association_in_node?(node, parent_association, child_associations, line_number)
|
|
267
|
+
return false unless node.respond_to?(:child_nodes)
|
|
268
|
+
|
|
269
|
+
if node.is_a?(Prism::CallNode) && %i[each find_each].include?(node.name) && node.block &&
|
|
270
|
+
receiver_calls?(node.receiver, parent_association)
|
|
271
|
+
parameters = node.block.parameters&.parameters&.requireds || []
|
|
272
|
+
names = parameters.map(&:name)
|
|
273
|
+
return true if !variable_written?(node.block.body, names) && child_associations.all? do |association|
|
|
274
|
+
call_on_variable_at_line?(node.block.body, association, names, line_number)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
node.child_nodes.compact.any? do |child|
|
|
279
|
+
nested_association_in_node?(child, parent_association, child_associations, line_number)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @rbs node: untyped
|
|
284
|
+
# @rbs method_name: Symbol
|
|
285
|
+
# @rbs names: Array[Symbol]
|
|
286
|
+
# @rbs line_number: Integer
|
|
287
|
+
# @rbs return: bool
|
|
288
|
+
def call_on_variable_at_line?(node, method_name, names, line_number)
|
|
289
|
+
return false unless node.respond_to?(:child_nodes)
|
|
290
|
+
|
|
291
|
+
if node.is_a?(Prism::CallNode) && node.name == method_name && node.location.start_line == line_number
|
|
292
|
+
receiver = find_root_receiver(node)
|
|
293
|
+
return true if receiver.is_a?(Prism::LocalVariableReadNode) && names.include?(receiver.name)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
if node.is_a?(Prism::CallNode) && node.block
|
|
297
|
+
parameters = node.block.parameters&.parameters&.requireds || []
|
|
298
|
+
return false if parameters.any? { |parameter| parameter.respond_to?(:name) && names.include?(parameter.name) }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
node.child_nodes.compact.any? do |child|
|
|
302
|
+
call_on_variable_at_line?(child, method_name, names, line_number)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# @rbs node: untyped
|
|
307
|
+
# @rbs method_name: Symbol
|
|
308
|
+
# @rbs return: bool
|
|
309
|
+
def receiver_calls?(node, method_name)
|
|
310
|
+
current = node
|
|
311
|
+
while current.is_a?(Prism::CallNode)
|
|
312
|
+
return true if current.name == method_name
|
|
313
|
+
|
|
314
|
+
current = current.receiver
|
|
315
|
+
end
|
|
316
|
+
false
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# @rbs node: untyped
|
|
320
|
+
# @rbs names: Array[Symbol]
|
|
321
|
+
# @rbs return: bool
|
|
322
|
+
def variable_written?(node, names)
|
|
323
|
+
return false unless node.respond_to?(:child_nodes)
|
|
324
|
+
return true if node.is_a?(Prism::LocalVariableWriteNode) && names.include?(node.name)
|
|
325
|
+
|
|
326
|
+
node.child_nodes.compact.any? { |child| variable_written?(child, names) }
|
|
327
|
+
end
|
|
173
328
|
end
|
|
174
329
|
end
|
|
175
330
|
end
|
|
@@ -10,26 +10,10 @@ module Bullematic
|
|
|
10
10
|
# @rbs @filepath: String?
|
|
11
11
|
|
|
12
12
|
class << self
|
|
13
|
-
#: () -> Hash[String, untyped]
|
|
14
|
-
def cache
|
|
15
|
-
@cache ||= {} #: Hash[String, untyped]
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
#: () -> void
|
|
19
|
-
def clear_cache
|
|
20
|
-
@cache = {} #: Hash[String, untyped]
|
|
21
|
-
end
|
|
22
|
-
|
|
23
13
|
# @rbs filepath: String
|
|
24
14
|
# @rbs return: untyped
|
|
25
15
|
def parse_file(filepath)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
result = Prism.parse_file(filepath)
|
|
29
|
-
raise ParseError, result.errors.map(&:message).join("\n") if result.failure?
|
|
30
|
-
|
|
31
|
-
cache[filepath] = result
|
|
32
|
-
result
|
|
16
|
+
new(File.binread(filepath), filepath: filepath).parse
|
|
33
17
|
end
|
|
34
18
|
end
|
|
35
19
|
|
|
@@ -25,13 +25,22 @@ module Bullematic
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# @rbs query_location: Finder::QueryLocation
|
|
28
|
-
# @rbs associations: Array[
|
|
29
|
-
# @rbs return:
|
|
28
|
+
# @rbs associations: Array[untyped]
|
|
29
|
+
# @rbs return: Symbol
|
|
30
30
|
def add_includes(query_location, associations)
|
|
31
|
-
|
|
31
|
+
associations = associations.uniq
|
|
32
|
+
return :unsupported if associations.empty?
|
|
33
|
+
|
|
34
|
+
return :already_present if already_has_includes?(query_location, associations)
|
|
32
35
|
|
|
33
36
|
strategy = Bullematic.configuration&.fix_strategy || :includes
|
|
34
37
|
insert_point = find_insert_point(query_location)
|
|
38
|
+
pending = @modifications.find { |mod| mod.type == :insert && mod.offset == insert_point }
|
|
39
|
+
if pending
|
|
40
|
+
pending.associations = (pending.associations + associations).uniq
|
|
41
|
+
pending.new_text = ".#{strategy}(#{format_associations(pending.associations)})"
|
|
42
|
+
return :changed
|
|
43
|
+
end
|
|
35
44
|
|
|
36
45
|
assoc_string = format_associations(associations)
|
|
37
46
|
new_text = ".#{strategy}(#{assoc_string})"
|
|
@@ -43,24 +52,22 @@ module Bullematic
|
|
|
43
52
|
new_text: new_text,
|
|
44
53
|
associations: associations
|
|
45
54
|
)
|
|
55
|
+
:changed
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
#: () -> String
|
|
49
59
|
def rewrite
|
|
50
60
|
return @source if @modifications.empty?
|
|
51
61
|
|
|
62
|
+
validate_modifications!
|
|
52
63
|
sorted = @modifications.sort_by { |m| -m.offset }
|
|
53
64
|
|
|
54
65
|
result = @source.dup
|
|
55
66
|
sorted.each do |mod|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
result[mod.offset, mod.byte_length] = mod.new_text
|
|
61
|
-
when :delete
|
|
62
|
-
result[mod.offset, mod.byte_length] = ""
|
|
63
|
-
end
|
|
67
|
+
before = result.byteslice(0, mod.offset)
|
|
68
|
+
after = result.byteslice(mod.offset + mod.byte_length..)
|
|
69
|
+
replacement = mod.type == :delete ? "" : mod.new_text
|
|
70
|
+
result = before + replacement + after
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
result
|
|
@@ -74,7 +81,7 @@ module Bullematic
|
|
|
74
81
|
node = query_location.node
|
|
75
82
|
receiver = query_location.receiver
|
|
76
83
|
|
|
77
|
-
if receiver.is_a?(Prism::ConstantReadNode)
|
|
84
|
+
if receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
|
|
78
85
|
receiver.location.end_offset
|
|
79
86
|
else
|
|
80
87
|
find_chain_insert_point(node)
|
|
@@ -95,7 +102,7 @@ module Bullematic
|
|
|
95
102
|
end
|
|
96
103
|
|
|
97
104
|
# @rbs query_location: Finder::QueryLocation
|
|
98
|
-
# @rbs associations: Array[
|
|
105
|
+
# @rbs associations: Array[untyped]
|
|
99
106
|
# @rbs return: bool
|
|
100
107
|
def already_has_includes?(query_location, associations)
|
|
101
108
|
existing = find_existing_includes(query_location.node)
|
|
@@ -121,31 +128,86 @@ module Bullematic
|
|
|
121
128
|
end
|
|
122
129
|
|
|
123
130
|
# @rbs call_node: untyped
|
|
124
|
-
# @rbs return: Array[
|
|
131
|
+
# @rbs return: Array[untyped]
|
|
125
132
|
def extract_associations_from_call(call_node)
|
|
126
133
|
return [] unless call_node.arguments
|
|
127
134
|
|
|
128
|
-
associations = [] #: Array[
|
|
129
|
-
call_node.arguments.arguments.each
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
associations = [] #: Array[untyped]
|
|
136
|
+
call_node.arguments.arguments.each { |arg| collect_literal_associations(arg, associations) }
|
|
137
|
+
associations
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @rbs node: untyped
|
|
141
|
+
# @rbs associations: Array[untyped]
|
|
142
|
+
# @rbs return: void
|
|
143
|
+
def collect_literal_associations(node, associations)
|
|
144
|
+
case node
|
|
145
|
+
when Prism::SymbolNode
|
|
146
|
+
associations << node.value.to_sym
|
|
147
|
+
when Prism::ArrayNode
|
|
148
|
+
node.elements.each { |element| collect_literal_associations(element, associations) }
|
|
149
|
+
when Prism::KeywordHashNode, Prism::HashNode
|
|
150
|
+
association = literal_association(node)
|
|
151
|
+
associations << association if association
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @rbs node: untyped
|
|
156
|
+
# @rbs return: untyped
|
|
157
|
+
def literal_association(node)
|
|
158
|
+
case node
|
|
159
|
+
when Prism::SymbolNode
|
|
160
|
+
node.value.to_sym
|
|
161
|
+
when Prism::ArrayNode
|
|
162
|
+
values = node.elements.map { |element| literal_association(element) }
|
|
163
|
+
values unless values.any?(&:nil?)
|
|
164
|
+
when Prism::KeywordHashNode, Prism::HashNode
|
|
165
|
+
pairs = node.elements.map do |element|
|
|
166
|
+
next unless element.is_a?(Prism::AssocNode)
|
|
167
|
+
|
|
168
|
+
key = literal_association(element.key)
|
|
169
|
+
value = literal_association(element.value)
|
|
170
|
+
[key, value] if key && value
|
|
137
171
|
end
|
|
172
|
+
pairs.to_h if pairs.none?(&:nil?)
|
|
138
173
|
end
|
|
139
|
-
associations
|
|
140
174
|
end
|
|
141
175
|
|
|
142
|
-
# @rbs associations: Array[
|
|
176
|
+
# @rbs associations: Array[untyped]
|
|
143
177
|
# @rbs return: String
|
|
144
178
|
def format_associations(associations)
|
|
145
|
-
|
|
146
|
-
|
|
179
|
+
associations.map { |association| format_association(association, false) }.join(", ")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @rbs association: untyped
|
|
183
|
+
# @rbs braces: bool
|
|
184
|
+
# @rbs return: String
|
|
185
|
+
def format_association(association, braces = true)
|
|
186
|
+
case association
|
|
187
|
+
when Hash
|
|
188
|
+
contents = association.map do |key, value|
|
|
189
|
+
key_source = key.to_s.match?(/\A[a-z_]\w*\z/) ? "#{key}:" : "#{key.inspect} =>"
|
|
190
|
+
"#{key_source} #{format_association(value)}"
|
|
191
|
+
end.join(", ")
|
|
192
|
+
braces ? "{ #{contents} }" : contents
|
|
193
|
+
when Array
|
|
194
|
+
"[#{association.map { |nested| format_association(nested) }.join(', ')}]"
|
|
147
195
|
else
|
|
148
|
-
|
|
196
|
+
association.inspect
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
#: () -> void
|
|
201
|
+
def validate_modifications!
|
|
202
|
+
@modifications.each do |modification|
|
|
203
|
+
limit = modification.offset + modification.byte_length
|
|
204
|
+
raise FixError, "edit is outside the source" if modification.offset.negative? || limit > @source.bytesize
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
ranges = @modifications.reject { |modification| modification.byte_length.zero? }
|
|
208
|
+
ranges.combination(2) do |left, right|
|
|
209
|
+
overlap = left.offset < right.offset + right.byte_length && right.offset < left.offset + left.byte_length
|
|
210
|
+
raise FixError, "overlapping edits" if overlap
|
|
149
211
|
end
|
|
150
212
|
end
|
|
151
213
|
end
|