archspec 0.3.0 → 0.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/README.md +30 -11
- data/lib/archspec/analyzer.rb +224 -59
- data/lib/archspec/architectures.rb +134 -28
- data/lib/archspec/cli.rb +42 -88
- data/lib/archspec/component_spec.rb +3 -0
- data/lib/archspec/definition.rb +12 -4
- data/lib/archspec/diagnostic.rb +7 -1
- data/lib/archspec/dsl.rb +220 -16
- data/lib/archspec/evaluator.rb +4 -3
- data/lib/archspec/formatters/explanation.rb +93 -0
- data/lib/archspec/model.rb +61 -13
- data/lib/archspec/rules/component_rules.rb +2 -0
- data/lib/archspec/rules/concern_rules.rb +67 -0
- data/lib/archspec/rules/cycle_rule.rb +2 -0
- data/lib/archspec/rules/dependency_rules.rb +65 -0
- data/lib/archspec/rules/naming_rules.rb +209 -0
- data/lib/archspec/rules/privacy_rule.rb +81 -0
- data/lib/archspec/rules/protocol_rules.rb +62 -13
- data/lib/archspec/{baseline.rb → todo.rb} +5 -1
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +40 -3
- metadata +21 -5
- data/lib/archspec/presets.rb +0 -11
- data/lib/archspec/rules/zeitwerk_rule.rb +0 -27
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b5ac200fd85432184c5043ce7540e45cb3bcc0013a37ecd77d07c34aca51bfd8
|
|
4
|
+
data.tar.gz: 5d3e6b1558c796c6784505b9637b5fa58fb0da9d7b85fd30e71b7a26689da471
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 71b4a46c3423cd47b0317559aea01038431b29f0fa8cbd1f72a403cf5e4a3bb64bf7649412752110d7a49e5e59abbc387dd3e3ec1251a11a1f7219d6fdd05877
|
|
7
|
+
data.tar.gz: 28f23d54d05d3d16663a5c728aa55846b45f240913cdada2954672059d9193e895c7e651434351bef50f30731ad08e8b5660680e17d97b294846745f769e7f75
|
data/README.md
CHANGED
|
@@ -67,7 +67,7 @@ Keep a hexagonal core away from adapters:
|
|
|
67
67
|
architecture :hexagonal
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
Check a modular monolith:
|
|
70
|
+
Check a modular monolith, and make packs go through a public API:
|
|
71
71
|
|
|
72
72
|
```ruby
|
|
73
73
|
architecture :modular_monolith,
|
|
@@ -79,6 +79,9 @@ architecture :modular_monolith,
|
|
|
79
79
|
allow: {
|
|
80
80
|
billing: %i[shared],
|
|
81
81
|
catalog: %i[shared]
|
|
82
|
+
},
|
|
83
|
+
public: {
|
|
84
|
+
billing: "packs/billing/app/public/**/*.rb"
|
|
82
85
|
}
|
|
83
86
|
```
|
|
84
87
|
|
|
@@ -89,7 +92,7 @@ component :controllers, in: "app/controllers/**/*.rb"
|
|
|
89
92
|
component :models, in: "app/models/**/*.rb"
|
|
90
93
|
component :services, in: "app/services/**/*.rb"
|
|
91
94
|
|
|
92
|
-
controllers.
|
|
95
|
+
controllers.can_only_use :models, :services
|
|
93
96
|
models.cannot_use :controllers
|
|
94
97
|
services.cannot_call :render, :redirect_to, :params, :session
|
|
95
98
|
services.cannot_instantiate_and_invoke
|
|
@@ -106,16 +109,22 @@ architecture :cqrs,
|
|
|
106
109
|
|
|
107
110
|
## What It Checks
|
|
108
111
|
|
|
109
|
-
- **Dependencies:** allowed and forbidden references between components
|
|
112
|
+
- **Dependencies:** allowed and forbidden references between components, in both directions
|
|
113
|
+
- **Privacy:** other components must go through a component's public API
|
|
114
|
+
- **Concerns:** a concern must not depend on the classes that include it
|
|
110
115
|
- **Layers:** dependency direction and cycles
|
|
111
116
|
- **Rails:** controller APIs kept out of models and services
|
|
112
|
-
- **Architectures:** Rails, vanilla Rails, layered, hexagonal, clean, modular monolith, CQRS,
|
|
117
|
+
- **Architectures:** Rails, vanilla Rails, layered, hexagonal, clean, modular monolith, CQRS, event-driven, and Ruby conventions bundles
|
|
113
118
|
- **Protocols:** required methods such as `resolve`, `perform`, or project-specific interfaces
|
|
119
|
+
- **Naming:** conventions on a component's public API, such as banning `get_`/`set_` or pairing `with_x` with `without_x`
|
|
114
120
|
- **Objects:** rules against one-shot `Something.new(...).whatever` command objects
|
|
115
|
-
- **Zeitwerk names:** conventional file names defining the expected constants
|
|
116
121
|
- **Empty components:** directories that must stay empty, like `app/services` in vanilla Rails
|
|
117
122
|
- **Suppressions:** narrow local exceptions with a reason
|
|
118
123
|
|
|
124
|
+
## Checking Zeitwerk Names
|
|
125
|
+
|
|
126
|
+
ArchSpec does not check Zeitwerk constant names. Zeitwerk does that itself. Add `Zeitwerk::Loader.eager_load_all` (or your loader's `eager_load`) to your test suite or CI. It raises on any file that does not define the constant its path implies, using your real inflector and ignores.
|
|
127
|
+
|
|
119
128
|
## Installation
|
|
120
129
|
|
|
121
130
|
Add ArchSpec to your Gemfile:
|
|
@@ -150,7 +159,7 @@ bundle exec archspec check
|
|
|
150
159
|
bundle exec archspec init
|
|
151
160
|
bundle exec archspec check
|
|
152
161
|
bundle exec archspec check --format json
|
|
153
|
-
bundle exec archspec check --update-
|
|
162
|
+
bundle exec archspec check --update-todo
|
|
154
163
|
bundle exec archspec explain app/models/user.rb
|
|
155
164
|
```
|
|
156
165
|
|
|
@@ -161,12 +170,15 @@ facts ArchSpec found.
|
|
|
161
170
|
|
|
162
171
|
Generated code should pass the same architecture checks as hand-written code.
|
|
163
172
|
|
|
164
|
-
After an AI-assisted change:
|
|
173
|
+
After an AI-assisted change, check the whole project, or just the files that changed:
|
|
165
174
|
|
|
166
175
|
```sh
|
|
167
176
|
bundle exec archspec check
|
|
177
|
+
bundle exec archspec check app/models/user.rb app/services
|
|
168
178
|
```
|
|
169
179
|
|
|
180
|
+
Passing paths still analyzes the project so dependencies resolve, but reports only violations in those paths. This keeps the loop tight after each edit.
|
|
181
|
+
|
|
170
182
|
If it fails, read the evidence before changing the spec:
|
|
171
183
|
|
|
172
184
|
```text
|
|
@@ -179,16 +191,17 @@ If it fails, read the evidence before changing the spec:
|
|
|
179
191
|
Most failures should be fixed in the generated code. Update the spec only when
|
|
180
192
|
the architecture decision itself has changed.
|
|
181
193
|
|
|
182
|
-
##
|
|
194
|
+
## Todo and Suppressions
|
|
183
195
|
|
|
184
|
-
Use a
|
|
196
|
+
Use a todo file when adopting ArchSpec in an existing app. It records the current
|
|
197
|
+
violations so they stop failing the build, leaving a list to burn down:
|
|
185
198
|
|
|
186
199
|
```ruby
|
|
187
|
-
|
|
200
|
+
todo "archspec_todo.yml"
|
|
188
201
|
```
|
|
189
202
|
|
|
190
203
|
```sh
|
|
191
|
-
bundle exec archspec check --update-
|
|
204
|
+
bundle exec archspec check --update-todo
|
|
192
205
|
```
|
|
193
206
|
|
|
194
207
|
Use local suppressions for deliberate exceptions:
|
|
@@ -210,6 +223,12 @@ This repository checks its own architecture:
|
|
|
210
223
|
bundle exec rake architecture
|
|
211
224
|
```
|
|
212
225
|
|
|
226
|
+
It also runs against pinned checkouts of large real-world Rails apps to catch crashes and false positives before they ship:
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
bundle exec rake torture
|
|
230
|
+
```
|
|
231
|
+
|
|
213
232
|
## License
|
|
214
233
|
|
|
215
234
|
Released under the MIT License.
|
data/lib/archspec/analyzer.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'prism'
|
|
4
|
-
require 'pathname'
|
|
5
4
|
|
|
6
5
|
module ArchSpec
|
|
7
6
|
module Analyzer
|
|
@@ -15,7 +14,6 @@ module ArchSpec
|
|
|
15
14
|
result = Prism.parse_file(path)
|
|
16
15
|
graph.add_file(
|
|
17
16
|
path: path,
|
|
18
|
-
expected_constant: expected_constant_for(path, root),
|
|
19
17
|
parse_errors: parse_errors_for(path, result.errors),
|
|
20
18
|
suppressions: suppressions_for(result.comments)
|
|
21
19
|
)
|
|
@@ -49,33 +47,6 @@ module ArchSpec
|
|
|
49
47
|
end.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }.to_set
|
|
50
48
|
end
|
|
51
49
|
|
|
52
|
-
def expected_constant_for(path, root)
|
|
53
|
-
relative = Pathname(path).relative_path_from(Pathname(root)).to_s
|
|
54
|
-
stem =
|
|
55
|
-
case relative
|
|
56
|
-
when %r{\Aapp/[^/]+/concerns/(.+)\.rb\z}
|
|
57
|
-
Regexp.last_match(1)
|
|
58
|
-
when %r{\Aapp/[^/]+/(.+)\.rb\z}
|
|
59
|
-
Regexp.last_match(1)
|
|
60
|
-
when %r{\Alib/(.+)\.rb\z}
|
|
61
|
-
Regexp.last_match(1)
|
|
62
|
-
when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/concerns/(.+)\.rb\z}
|
|
63
|
-
Regexp.last_match(1)
|
|
64
|
-
when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/(.+)\.rb\z}
|
|
65
|
-
Regexp.last_match(1)
|
|
66
|
-
else
|
|
67
|
-
return nil
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
camelize_path(stem)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def camelize_path(path)
|
|
74
|
-
path.split('/').map do |part|
|
|
75
|
-
part.split('_').map { |word| word[0] ? word[0].upcase + word[1..] : word }.join
|
|
76
|
-
end.join('::')
|
|
77
|
-
end
|
|
78
|
-
|
|
79
50
|
def suppressions_for(comments)
|
|
80
51
|
SuppressionParser.parse(comments)
|
|
81
52
|
end
|
|
@@ -164,24 +135,37 @@ module ArchSpec
|
|
|
164
135
|
extend: :extends
|
|
165
136
|
}.freeze
|
|
166
137
|
|
|
167
|
-
|
|
138
|
+
ATTR_MESSAGES = {
|
|
139
|
+
attr_reader: %i[reader],
|
|
140
|
+
attr_writer: %i[writer],
|
|
141
|
+
attr_accessor: %i[reader writer]
|
|
142
|
+
}.freeze
|
|
143
|
+
|
|
144
|
+
def visit(graph, path, node, current_constant: nil, namespace: [], visibility: :public, default_scope: :instance)
|
|
168
145
|
return unless node
|
|
169
146
|
|
|
170
147
|
case node
|
|
171
148
|
when Prism::ProgramNode, Prism::StatementsNode
|
|
172
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
149
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
150
|
+
visibility: visibility, default_scope: default_scope)
|
|
173
151
|
when Prism::ClassNode
|
|
174
152
|
visit_class(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
175
153
|
when Prism::ModuleNode
|
|
176
154
|
visit_module(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
155
|
+
when Prism::SingletonClassNode
|
|
156
|
+
visit_singleton_class(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
157
|
+
visibility: visibility, default_scope: default_scope)
|
|
177
158
|
when Prism::DefNode
|
|
178
|
-
visit_def(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
159
|
+
visit_def(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
160
|
+
visibility: visibility, default_scope: default_scope)
|
|
179
161
|
when Prism::CallNode
|
|
180
|
-
visit_call(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
162
|
+
visit_call(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
163
|
+
visibility: visibility, default_scope: default_scope)
|
|
181
164
|
when Prism::ConstantPathNode, Prism::ConstantReadNode
|
|
182
165
|
add_constant_reference(graph, path, node, current_constant)
|
|
183
166
|
else
|
|
184
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
167
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
168
|
+
visibility: visibility, default_scope: default_scope)
|
|
185
169
|
end
|
|
186
170
|
end
|
|
187
171
|
|
|
@@ -197,18 +181,28 @@ module ArchSpec
|
|
|
197
181
|
)
|
|
198
182
|
|
|
199
183
|
if node.superclass
|
|
200
|
-
superclass = constant_reference_name(node.superclass)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
184
|
+
superclass = constant_reference_name(node.superclass) if constant_node?(node.superclass)
|
|
185
|
+
|
|
186
|
+
if superclass
|
|
187
|
+
constant.superclass = superclass
|
|
188
|
+
graph.add_edge(
|
|
189
|
+
type: :inherits_from,
|
|
190
|
+
from_path: path,
|
|
191
|
+
from_constant: constant.name,
|
|
192
|
+
to: superclass,
|
|
193
|
+
location: SourceLocation.from_prism(path, node.superclass.location)
|
|
194
|
+
)
|
|
195
|
+
else
|
|
196
|
+
# Dynamic superclass (Struct.new, DelegateClass(...)): no
|
|
197
|
+
# inherits_from edge, but constants inside still count as
|
|
198
|
+
# references, and ancestry stays marked unresolved.
|
|
199
|
+
constant.superclass = node.superclass.slice
|
|
200
|
+
visit(graph, path, node.superclass, current_constant: constant.name,
|
|
201
|
+
namespace: constant.name.split('::'))
|
|
202
|
+
end
|
|
209
203
|
end
|
|
210
204
|
|
|
211
|
-
|
|
205
|
+
visit_constant_body(graph, path, constant, node.body, constant.name.split('::'))
|
|
212
206
|
end
|
|
213
207
|
|
|
214
208
|
def visit_module(graph, path, node, current_constant:, namespace:)
|
|
@@ -220,17 +214,67 @@ module ArchSpec
|
|
|
220
214
|
location: SourceLocation.from_prism(path, node.location)
|
|
221
215
|
)
|
|
222
216
|
|
|
223
|
-
|
|
217
|
+
visit_constant_body(graph, path, constant, node.body, constant.name.split('::'))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A +class << self+ (or +class << SomeConstant+) block defines methods on a
|
|
221
|
+
# constant's singleton, so they are class methods, and +private+ inside it
|
|
222
|
+
# applies to them. Walk the body with +default_scope: :class+ against the
|
|
223
|
+
# target constant. An unknown target (+class << variable+) still has its
|
|
224
|
+
# body visited for edges, but no methods are attributed.
|
|
225
|
+
def visit_singleton_class(graph, path, node, current_constant:, namespace:, visibility:, default_scope:)
|
|
226
|
+
target = singleton_target(node.expression, current_constant)
|
|
227
|
+
constant = target && graph.constants_named(target).find { |candidate| candidate.path == path }
|
|
228
|
+
|
|
229
|
+
if constant
|
|
230
|
+
visit_constant_body(graph, path, constant, node.body, namespace, default_scope: :class)
|
|
231
|
+
else
|
|
232
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
233
|
+
visibility: visibility, default_scope: default_scope)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def singleton_target(expression, current_constant)
|
|
238
|
+
return current_constant if expression.is_a?(Prism::SelfNode)
|
|
239
|
+
|
|
240
|
+
constant_reference_name(expression) if constant_node?(expression)
|
|
224
241
|
end
|
|
225
242
|
|
|
226
|
-
|
|
243
|
+
# Walks a class or module body in source order, tracking method visibility
|
|
244
|
+
# so +private+/+protected+ and their inline and symbol-list forms mark the
|
|
245
|
+
# methods they cover. +default_scope+ is +:class+ inside a singleton class,
|
|
246
|
+
# so bare +def+s there are class methods. Every statement is still handed to
|
|
247
|
+
# +visit+, so all other facts (calls, references, mixins) are recorded
|
|
248
|
+
# exactly as before.
|
|
249
|
+
def visit_constant_body(graph, path, constant, body, namespace, default_scope: :instance)
|
|
250
|
+
return unless body
|
|
251
|
+
|
|
252
|
+
unless body.is_a?(Prism::StatementsNode)
|
|
253
|
+
return visit(graph, path, body, current_constant: constant.name, namespace: namespace,
|
|
254
|
+
default_scope: default_scope)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
visibility = :public
|
|
258
|
+
body.body.each do |statement|
|
|
259
|
+
if (modifier = visibility_modifier(statement))
|
|
260
|
+
visibility = apply_visibility_modifier(graph, path, constant, statement, namespace, visibility, modifier,
|
|
261
|
+
default_scope)
|
|
262
|
+
else
|
|
263
|
+
visit(graph, path, statement, current_constant: constant.name, namespace: namespace,
|
|
264
|
+
visibility: visibility, default_scope: default_scope)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def visit_def(graph, path, node, current_constant:, namespace:, visibility: :public, default_scope: :instance)
|
|
227
270
|
if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
|
|
228
271
|
candidate.path == path
|
|
229
272
|
end)
|
|
230
|
-
|
|
231
|
-
|
|
273
|
+
location = SourceLocation.from_prism(path, node.location)
|
|
274
|
+
if node.receiver || default_scope == :class
|
|
275
|
+
constant.add_class_method(node.name, location: location, visibility: visibility)
|
|
232
276
|
else
|
|
233
|
-
constant.add_instance_method(node.name, location:
|
|
277
|
+
constant.add_instance_method(node.name, location: location, visibility: visibility)
|
|
234
278
|
end
|
|
235
279
|
end
|
|
236
280
|
|
|
@@ -248,10 +292,10 @@ module ArchSpec
|
|
|
248
292
|
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
249
293
|
end
|
|
250
294
|
|
|
251
|
-
def visit_call(graph, path, node, current_constant:, namespace:)
|
|
295
|
+
def visit_call(graph, path, node, current_constant:, namespace:, visibility: :public, default_scope: :instance)
|
|
252
296
|
unless node.message
|
|
253
|
-
return visit_children(graph, path, node, current_constant: current_constant,
|
|
254
|
-
|
|
297
|
+
return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
298
|
+
visibility: visibility, default_scope: default_scope)
|
|
255
299
|
end
|
|
256
300
|
|
|
257
301
|
message = node.message.to_sym
|
|
@@ -277,6 +321,8 @@ module ArchSpec
|
|
|
277
321
|
)
|
|
278
322
|
end
|
|
279
323
|
|
|
324
|
+
record_generated_methods(graph, path, node, message, current_constant, location, visibility, default_scope)
|
|
325
|
+
|
|
280
326
|
if (edge_type = MIXIN_MESSAGES[message])
|
|
281
327
|
constant_arguments(node).each do |constant_name|
|
|
282
328
|
if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
|
|
@@ -300,7 +346,8 @@ module ArchSpec
|
|
|
300
346
|
from_path: path,
|
|
301
347
|
from_constant: current_constant,
|
|
302
348
|
to: message,
|
|
303
|
-
location: location
|
|
349
|
+
location: location,
|
|
350
|
+
receiver: receiver_kind(node)
|
|
304
351
|
)
|
|
305
352
|
|
|
306
353
|
if DYNAMIC_MESSAGES.include?(message)
|
|
@@ -314,22 +361,79 @@ module ArchSpec
|
|
|
314
361
|
)
|
|
315
362
|
end
|
|
316
363
|
|
|
317
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
364
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
365
|
+
visibility: visibility, default_scope: default_scope)
|
|
318
366
|
end
|
|
319
367
|
|
|
320
368
|
def add_constant_reference(graph, path, node, current_constant)
|
|
369
|
+
name = constant_reference_name(node)
|
|
370
|
+
return unless name
|
|
371
|
+
|
|
321
372
|
graph.add_edge(
|
|
322
373
|
type: :references_constant,
|
|
323
374
|
from_path: path,
|
|
324
375
|
from_constant: current_constant,
|
|
325
|
-
to:
|
|
376
|
+
to: name,
|
|
326
377
|
location: SourceLocation.from_prism(path, node.location)
|
|
327
378
|
)
|
|
328
379
|
end
|
|
329
380
|
|
|
330
|
-
def visit_children(graph, path, node, current_constant:, namespace:)
|
|
381
|
+
def visit_children(graph, path, node, current_constant:, namespace:, visibility: :public, default_scope: :instance)
|
|
331
382
|
node.child_nodes.compact.each do |child|
|
|
332
|
-
visit(graph, path, child, current_constant: current_constant, namespace: namespace
|
|
383
|
+
visit(graph, path, child, current_constant: current_constant, namespace: namespace,
|
|
384
|
+
visibility: visibility, default_scope: default_scope)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Maps a visibility call to [visibility, forced_scope]. A nil forced_scope
|
|
389
|
+
# means the call follows the context (instance methods in a class body,
|
|
390
|
+
# class methods inside +class << self+); +*_class_method+ always targets
|
|
391
|
+
# class methods.
|
|
392
|
+
VISIBILITY_MODIFIERS = {
|
|
393
|
+
private: [:private, nil],
|
|
394
|
+
protected: [:protected, nil],
|
|
395
|
+
public: [:public, nil],
|
|
396
|
+
private_class_method: [:private, :class],
|
|
397
|
+
public_class_method: [:public, :class]
|
|
398
|
+
}.freeze
|
|
399
|
+
|
|
400
|
+
# The [visibility, forced_scope] a bare visibility call sets, or nil when
|
|
401
|
+
# the node is not one. Only receiverless calls count, so +obj.private+ is
|
|
402
|
+
# ignored.
|
|
403
|
+
def visibility_modifier(node)
|
|
404
|
+
return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
405
|
+
|
|
406
|
+
VISIBILITY_MODIFIERS[node.message&.to_sym]
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Applies a visibility call and returns the default visibility for the
|
|
410
|
+
# statements that follow it. A bare +private+ changes that default; the
|
|
411
|
+
# inline (+private def foo+) and symbol-list (+private :foo+) forms mark
|
|
412
|
+
# only the methods they name and leave the default unchanged.
|
|
413
|
+
def apply_visibility_modifier(graph, path, constant, node, namespace, current, spec, default_scope)
|
|
414
|
+
visibility, forced_scope = spec
|
|
415
|
+
scope = forced_scope || default_scope
|
|
416
|
+
arguments = node.arguments&.arguments || []
|
|
417
|
+
definitions = arguments.select { |argument| argument.is_a?(Prism::DefNode) }
|
|
418
|
+
names = arguments.select { |argument| argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode) }
|
|
419
|
+
|
|
420
|
+
if definitions.any?
|
|
421
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
422
|
+
visibility: visibility, default_scope: default_scope)
|
|
423
|
+
current
|
|
424
|
+
elsif names.any?
|
|
425
|
+
names.each { |name| constant.set_visibility(name.unescaped.to_sym, scope, visibility) }
|
|
426
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
427
|
+
visibility: current, default_scope: default_scope)
|
|
428
|
+
current
|
|
429
|
+
elsif forced_scope.nil?
|
|
430
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
431
|
+
visibility: visibility, default_scope: default_scope)
|
|
432
|
+
visibility
|
|
433
|
+
else
|
|
434
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
435
|
+
visibility: current, default_scope: default_scope)
|
|
436
|
+
current
|
|
333
437
|
end
|
|
334
438
|
end
|
|
335
439
|
|
|
@@ -349,27 +453,88 @@ module ArchSpec
|
|
|
349
453
|
end || []
|
|
350
454
|
end
|
|
351
455
|
|
|
456
|
+
# attr_*, Rails attribute, and unprefixed delegate calls define methods;
|
|
457
|
+
# without them, a class calling its own reader looks like a foreign call.
|
|
458
|
+
def record_generated_methods(graph, path, node, message, current_constant, location, visibility, default_scope)
|
|
459
|
+
return unless current_constant && node.receiver.nil?
|
|
460
|
+
return unless ATTR_MESSAGES.key?(message) || %i[attribute delegate].include?(message)
|
|
461
|
+
|
|
462
|
+
constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path }
|
|
463
|
+
return unless constant
|
|
464
|
+
|
|
465
|
+
names = generated_method_names(constant, node, message)
|
|
466
|
+
return if message == :delegate && keyword_argument?(node, :prefix)
|
|
467
|
+
|
|
468
|
+
adder = default_scope == :class ? :add_class_method : :add_instance_method
|
|
469
|
+
names.each do |name|
|
|
470
|
+
kinds = message == :attribute ? %i[reader writer] : ATTR_MESSAGES.fetch(message, %i[reader])
|
|
471
|
+
constant.public_send(adder, name, location: location, visibility: visibility) if kinds.include?(:reader)
|
|
472
|
+
constant.public_send(adder, :"#{name}=", location: location, visibility: visibility) if kinds.include?(:writer)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Active Record and Active Model take one attribute name followed by an
|
|
477
|
+
# optional type, which may itself be a symbol. CurrentAttributes instead
|
|
478
|
+
# accepts any number of names, so retain every literal name there.
|
|
479
|
+
def generated_method_names(constant, node, message)
|
|
480
|
+
names = symbol_arguments(node)
|
|
481
|
+
return names unless message == :attribute
|
|
482
|
+
return names if constant.superclass == 'ActiveSupport::CurrentAttributes'
|
|
483
|
+
|
|
484
|
+
names.first(1)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def symbol_arguments(node)
|
|
488
|
+
node.arguments&.arguments&.filter_map do |argument|
|
|
489
|
+
argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
|
|
490
|
+
end || []
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def keyword_argument?(node, name)
|
|
494
|
+
node.arguments&.arguments&.any? do |argument|
|
|
495
|
+
argument.is_a?(Prism::KeywordHashNode) && argument.elements.any? do |element|
|
|
496
|
+
element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
|
|
497
|
+
element.key.unescaped.to_sym == name
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def receiver_kind(node)
|
|
503
|
+
receiver = node.receiver
|
|
504
|
+
return :none if receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
505
|
+
return :constant if constant_node?(receiver)
|
|
506
|
+
|
|
507
|
+
:other
|
|
508
|
+
end
|
|
509
|
+
|
|
352
510
|
def instantiates_and_invokes(node)
|
|
353
511
|
receiver = node.receiver
|
|
354
512
|
return unless receiver.is_a?(Prism::CallNode) && receiver.message&.to_sym == :new
|
|
355
513
|
|
|
356
|
-
|
|
514
|
+
receiver_node = receiver.receiver
|
|
515
|
+
name = (constant_reference_name(receiver_node) if constant_node?(receiver_node)) || receiver_node&.slice
|
|
357
516
|
"#{name}##{node.message}"
|
|
358
517
|
end
|
|
359
518
|
|
|
519
|
+
# class Users::RolesController inside module Admin defines
|
|
520
|
+
# Admin::Users::RolesController, so compact paths join the namespace too.
|
|
360
521
|
def qualified_constant_name(node, namespace)
|
|
361
522
|
raw = constant_reference_name(node)
|
|
362
523
|
absolute = node.respond_to?(:full_name_parts) && node.full_name_parts.first == :""
|
|
363
524
|
|
|
364
|
-
if absolute ||
|
|
525
|
+
if absolute || namespace.empty?
|
|
365
526
|
raw
|
|
366
527
|
else
|
|
367
528
|
"#{namespace.join('::')}::#{raw}"
|
|
368
529
|
end
|
|
369
530
|
end
|
|
370
531
|
|
|
532
|
+
# nil when the path has dynamic parts (self.class::FOO): there is no
|
|
533
|
+
# static name to check against.
|
|
371
534
|
def constant_reference_name(node)
|
|
372
535
|
node.full_name.to_s.sub(/\A::/, '')
|
|
536
|
+
rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
|
|
537
|
+
nil
|
|
373
538
|
end
|
|
374
539
|
|
|
375
540
|
def constant_node?(node)
|