archspec 0.4.0 → 1.0.0.rc1
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 +29 -8
- data/lib/archspec/analyzer.rb +217 -84
- data/lib/archspec/architectures.rb +130 -98
- data/lib/archspec/cli.rb +113 -105
- data/lib/archspec/definition.rb +1 -6
- data/lib/archspec/diagnostic.rb +2 -0
- data/lib/archspec/dsl.rb +50 -43
- data/lib/archspec/error.rb +7 -0
- data/lib/archspec/evaluator.rb +6 -8
- data/lib/archspec/formatters/explanation.rb +123 -0
- data/lib/archspec/formatters/style.rb +39 -0
- data/lib/archspec/formatters/text.rb +92 -8
- data/lib/archspec/model.rb +142 -46
- data/lib/archspec/rules/component_rules.rb +1 -1
- data/lib/archspec/rules/concern_rules.rb +3 -3
- data/lib/archspec/rules/cycle_rule.rb +3 -3
- data/lib/archspec/rules/dependency_rules.rb +11 -19
- data/lib/archspec/rules/naming_rules.rb +237 -0
- data/lib/archspec/rules/privacy_rule.rb +3 -3
- data/lib/archspec/rules/protocol_rules.rb +10 -7
- data/lib/archspec/source_location.rb +8 -2
- data/lib/archspec/todo.rb +17 -2
- data/lib/archspec/value_object.rb +4 -0
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +10 -15
- metadata +6 -4
- data/lib/archspec/presets.rb +0 -14
- data/lib/archspec/rules/zeitwerk_rule.rb +0 -51
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24d73489bf5bdd8a56deffac28bbcd11aab271db3281afcce4e0aa21c93de28b
|
|
4
|
+
data.tar.gz: 9c6303777aededa6ce4f7780943a5a7257b33cbef978e50369296d1a4d0b0a8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c69995b8ce63a81ed15be9233a4f6199deb5faa710a0853613150d464229b653a4d7e122b032ba417e6048b7cf1285ee6a9272ff0217071b8cd727ed02add63
|
|
7
|
+
data.tar.gz: 8de9a4380f15a1c8f197980207ba20939bfac7a9a7a61e9e297eb57b517c491eb9017ab91c22057b3d88536c609bac385a6fddc311cb76b8915b66c64c5dc9dc
|
data/README.md
CHANGED
|
@@ -92,7 +92,7 @@ component :controllers, in: "app/controllers/**/*.rb"
|
|
|
92
92
|
component :models, in: "app/models/**/*.rb"
|
|
93
93
|
component :services, in: "app/services/**/*.rb"
|
|
94
94
|
|
|
95
|
-
controllers.
|
|
95
|
+
controllers.can_only_use :models, :services
|
|
96
96
|
models.cannot_use :controllers
|
|
97
97
|
services.cannot_call :render, :redirect_to, :params, :session
|
|
98
98
|
services.cannot_instantiate_and_invoke
|
|
@@ -114,13 +114,17 @@ architecture :cqrs,
|
|
|
114
114
|
- **Concerns:** a concern must not depend on the classes that include it
|
|
115
115
|
- **Layers:** dependency direction and cycles
|
|
116
116
|
- **Rails:** controller APIs kept out of models and services
|
|
117
|
-
- **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
|
|
118
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`
|
|
119
120
|
- **Objects:** rules against one-shot `Something.new(...).whatever` command objects
|
|
120
|
-
- **Zeitwerk names:** conventional file names defining the expected constants
|
|
121
121
|
- **Empty components:** directories that must stay empty, like `app/services` in vanilla Rails
|
|
122
122
|
- **Suppressions:** narrow local exceptions with a reason
|
|
123
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
|
+
|
|
124
128
|
## Installation
|
|
125
129
|
|
|
126
130
|
Add ArchSpec to your Gemfile:
|
|
@@ -160,7 +164,18 @@ bundle exec archspec explain app/models/user.rb
|
|
|
160
164
|
```
|
|
161
165
|
|
|
162
166
|
`explain` shows why a file or constant belongs to a component and which outgoing
|
|
163
|
-
facts ArchSpec found
|
|
167
|
+
facts ArchSpec found:
|
|
168
|
+
|
|
169
|
+
```text
|
|
170
|
+
app/models/user.rb
|
|
171
|
+
|
|
172
|
+
defined constants: User
|
|
173
|
+
components:
|
|
174
|
+
models: matched file pattern app/models/**/*.rb
|
|
175
|
+
outgoing facts:
|
|
176
|
+
1:14 │ inherits from ApplicationRecord
|
|
177
|
+
2:3 │ references UsersController
|
|
178
|
+
```
|
|
164
179
|
|
|
165
180
|
## Checking AI-Written Code
|
|
166
181
|
|
|
@@ -178,10 +193,16 @@ Passing paths still analyzes the project so dependencies resolve, but reports on
|
|
|
178
193
|
If it fails, read the evidence before changing the spec:
|
|
179
194
|
|
|
180
195
|
```text
|
|
181
|
-
[dependencies.forbid]
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
196
|
+
[error] models must not depend on controllers [dependencies.forbid]
|
|
197
|
+
|
|
198
|
+
app/models/user.rb:2:3
|
|
199
|
+
|
|
200
|
+
1 │ class User < ApplicationRecord
|
|
201
|
+
→ 2 │ UsersController
|
|
202
|
+
│ ^~~~~~~~~~~~~~~
|
|
203
|
+
3 │ end
|
|
204
|
+
|
|
205
|
+
note: User references UsersController
|
|
185
206
|
```
|
|
186
207
|
|
|
187
208
|
Most failures should be fixed in the generated code. Update the spec only when
|
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, definition.inflections),
|
|
19
17
|
parse_errors: parse_errors_for(path, result.errors),
|
|
20
18
|
suppressions: suppressions_for(result.comments)
|
|
21
19
|
)
|
|
@@ -49,38 +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, inflections = {})
|
|
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, inflections)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Rails-style acronyms: inflections apply to whole path segments first,
|
|
74
|
-
# then to each snake_case word (inflect "api" => "API" fixes both api.rb
|
|
75
|
-
# and api_client.rb).
|
|
76
|
-
def camelize_path(path, inflections = {})
|
|
77
|
-
path.split('/').map do |part|
|
|
78
|
-
inflections[part] || part.split('_').map do |word|
|
|
79
|
-
inflections[word] || (word[0] ? word[0].upcase + word[1..] : word)
|
|
80
|
-
end.join
|
|
81
|
-
end.join('::')
|
|
82
|
-
end
|
|
83
|
-
|
|
84
50
|
def suppressions_for(comments)
|
|
85
51
|
SuppressionParser.parse(comments)
|
|
86
52
|
end
|
|
@@ -115,7 +81,9 @@ module ArchSpec
|
|
|
115
81
|
when 'next-line'
|
|
116
82
|
suppressions << Suppression.new(rule, line + 1, line + 1, reason)
|
|
117
83
|
else
|
|
118
|
-
|
|
84
|
+
# The block form covers its own line too, so a trailing
|
|
85
|
+
# `# archspec:disable RULE` works like RuboCop's.
|
|
86
|
+
active[rule] << [line, reason]
|
|
119
87
|
end
|
|
120
88
|
elsif (match = text.match(ENABLE_PATTERN))
|
|
121
89
|
rule = normalize_rule(match[1])
|
|
@@ -175,36 +143,49 @@ module ArchSpec
|
|
|
175
143
|
attr_accessor: %i[reader writer]
|
|
176
144
|
}.freeze
|
|
177
145
|
|
|
178
|
-
def visit(graph, path, node, current_constant: nil, namespace: []
|
|
146
|
+
def visit(graph, path, node, current_constant: nil, namespace: [], nesting: [], visibility: :public,
|
|
147
|
+
default_scope: :instance)
|
|
179
148
|
return unless node
|
|
180
149
|
|
|
181
150
|
case node
|
|
182
151
|
when Prism::ProgramNode, Prism::StatementsNode
|
|
183
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
152
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
|
|
153
|
+
visibility: visibility, default_scope: default_scope)
|
|
184
154
|
when Prism::ClassNode
|
|
185
|
-
visit_class(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
155
|
+
visit_class(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
|
|
186
156
|
when Prism::ModuleNode
|
|
187
|
-
visit_module(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
157
|
+
visit_module(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
|
|
158
|
+
when Prism::SingletonClassNode
|
|
159
|
+
visit_singleton_class(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
160
|
+
nesting: nesting, visibility: visibility,
|
|
161
|
+
default_scope: default_scope)
|
|
188
162
|
when Prism::DefNode
|
|
189
|
-
visit_def(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
163
|
+
visit_def(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
164
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
190
165
|
when Prism::CallNode
|
|
191
|
-
visit_call(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
166
|
+
visit_call(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
167
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
192
168
|
when Prism::ConstantPathNode, Prism::ConstantReadNode
|
|
193
|
-
add_constant_reference(graph, path, node, current_constant)
|
|
169
|
+
add_constant_reference(graph, path, node, current_constant, nesting)
|
|
194
170
|
else
|
|
195
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
171
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
|
|
172
|
+
visibility: visibility, default_scope: default_scope)
|
|
196
173
|
end
|
|
197
174
|
end
|
|
198
175
|
|
|
199
176
|
private
|
|
200
177
|
|
|
201
|
-
def visit_class(graph, path, node, current_constant:, namespace:)
|
|
178
|
+
def visit_class(graph, path, node, current_constant:, namespace:, nesting:)
|
|
202
179
|
name = qualified_constant_name(node.constant_path, namespace)
|
|
180
|
+
return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
181
|
+
nesting: nesting) unless name
|
|
182
|
+
|
|
203
183
|
constant = graph.add_constant(
|
|
204
184
|
name: name,
|
|
205
185
|
kind: :class,
|
|
206
186
|
path: path,
|
|
207
|
-
location: SourceLocation.from_prism(path, node.location)
|
|
187
|
+
location: SourceLocation.from_prism(path, node.location),
|
|
188
|
+
nesting: nesting
|
|
208
189
|
)
|
|
209
190
|
|
|
210
191
|
if node.superclass
|
|
@@ -217,41 +198,100 @@ module ArchSpec
|
|
|
217
198
|
from_path: path,
|
|
218
199
|
from_constant: constant.name,
|
|
219
200
|
to: superclass,
|
|
220
|
-
location: SourceLocation.from_prism(path, node.superclass.location)
|
|
201
|
+
location: SourceLocation.from_prism(path, node.superclass.location),
|
|
202
|
+
lexical_nesting: nesting
|
|
221
203
|
)
|
|
222
204
|
else
|
|
223
205
|
# Dynamic superclass (Struct.new, DelegateClass(...)): no
|
|
224
206
|
# inherits_from edge, but constants inside still count as
|
|
225
207
|
# references, and ancestry stays marked unresolved.
|
|
226
208
|
constant.superclass = node.superclass.slice
|
|
227
|
-
visit(graph, path, node.superclass, current_constant: constant.name,
|
|
228
|
-
|
|
209
|
+
visit(graph, path, node.superclass, current_constant: constant.name, namespace: namespace,
|
|
210
|
+
nesting: nesting)
|
|
229
211
|
end
|
|
230
212
|
end
|
|
231
213
|
|
|
232
|
-
|
|
214
|
+
visit_constant_body(graph, path, constant, node.body, constant.name.split('::'),
|
|
215
|
+
nesting: [constant.name] + nesting)
|
|
233
216
|
end
|
|
234
217
|
|
|
235
|
-
def visit_module(graph, path, node, current_constant:, namespace:)
|
|
218
|
+
def visit_module(graph, path, node, current_constant:, namespace:, nesting:)
|
|
236
219
|
name = qualified_constant_name(node.constant_path, namespace)
|
|
220
|
+
return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
221
|
+
nesting: nesting) unless name
|
|
222
|
+
|
|
237
223
|
constant = graph.add_constant(
|
|
238
224
|
name: name,
|
|
239
225
|
kind: :module,
|
|
240
226
|
path: path,
|
|
241
|
-
location: SourceLocation.from_prism(path, node.location)
|
|
227
|
+
location: SourceLocation.from_prism(path, node.location),
|
|
228
|
+
nesting: nesting
|
|
242
229
|
)
|
|
243
230
|
|
|
244
|
-
|
|
231
|
+
visit_constant_body(graph, path, constant, node.body, constant.name.split('::'),
|
|
232
|
+
nesting: [constant.name] + nesting)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# A +class << self+ (or +class << SomeConstant+) block defines methods on a
|
|
236
|
+
# constant's singleton, so they are class methods, and +private+ inside it
|
|
237
|
+
# applies to them. Walk the body with +default_scope: :class+ against the
|
|
238
|
+
# target constant. An unknown target (+class << variable+) still has its
|
|
239
|
+
# body visited for edges, but no methods are attributed.
|
|
240
|
+
def visit_singleton_class(graph, path, node, current_constant:, namespace:, nesting:, visibility:, default_scope:)
|
|
241
|
+
target = singleton_target(node.expression, current_constant)
|
|
242
|
+
constant = target && graph.constants_named(target).find { |candidate| candidate.path == path }
|
|
243
|
+
|
|
244
|
+
if constant
|
|
245
|
+
visit_constant_body(graph, path, constant, node.body, namespace, nesting: nesting, default_scope: :class)
|
|
246
|
+
else
|
|
247
|
+
visit_children(graph, path, node, current_constant: nil, namespace: namespace,
|
|
248
|
+
nesting: nesting, visibility: visibility, default_scope: :class)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def singleton_target(expression, current_constant)
|
|
253
|
+
return current_constant if expression.is_a?(Prism::SelfNode)
|
|
254
|
+
|
|
255
|
+
constant_reference_name(expression) if constant_node?(expression)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Walks a class or module body in source order, tracking method visibility
|
|
259
|
+
# so +private+/+protected+ and their inline and symbol-list forms mark the
|
|
260
|
+
# methods they cover. +default_scope+ is +:class+ inside a singleton class,
|
|
261
|
+
# so bare +def+s there are class methods. Every statement is still handed to
|
|
262
|
+
# +visit+, so all other facts (calls, references, mixins) are recorded
|
|
263
|
+
# exactly as before.
|
|
264
|
+
def visit_constant_body(graph, path, constant, body, namespace, nesting:, default_scope: :instance)
|
|
265
|
+
return unless body
|
|
266
|
+
|
|
267
|
+
unless body.is_a?(Prism::StatementsNode)
|
|
268
|
+
return visit(graph, path, body, current_constant: constant.name, namespace: namespace, nesting: nesting,
|
|
269
|
+
default_scope: default_scope)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
visibility = :public
|
|
273
|
+
body.body.each do |statement|
|
|
274
|
+
if (modifier = visibility_modifier(statement))
|
|
275
|
+
visibility = apply_visibility_modifier(graph, path, constant, statement, namespace, visibility, modifier,
|
|
276
|
+
default_scope, nesting)
|
|
277
|
+
else
|
|
278
|
+
visit(graph, path, statement, current_constant: constant.name, namespace: namespace,
|
|
279
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
245
282
|
end
|
|
246
283
|
|
|
247
|
-
def visit_def(graph, path, node, current_constant:, namespace:
|
|
248
|
-
|
|
284
|
+
def visit_def(graph, path, node, current_constant:, namespace:, nesting:, visibility: :public,
|
|
285
|
+
default_scope: :instance)
|
|
286
|
+
owner = method_definition_owner(graph, node, current_constant, nesting)
|
|
287
|
+
if owner && (constant = graph.constants_named(owner).find do |candidate|
|
|
249
288
|
candidate.path == path
|
|
250
289
|
end)
|
|
251
|
-
|
|
252
|
-
|
|
290
|
+
location = SourceLocation.from_prism(path, node.location)
|
|
291
|
+
if node.receiver || default_scope == :class
|
|
292
|
+
constant.add_class_method(node.name, location: location, visibility: visibility)
|
|
253
293
|
else
|
|
254
|
-
constant.add_instance_method(node.name, location:
|
|
294
|
+
constant.add_instance_method(node.name, location: location, visibility: visibility)
|
|
255
295
|
end
|
|
256
296
|
end
|
|
257
297
|
|
|
@@ -266,13 +306,24 @@ module ArchSpec
|
|
|
266
306
|
)
|
|
267
307
|
end
|
|
268
308
|
|
|
269
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
309
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
|
|
270
310
|
end
|
|
271
311
|
|
|
272
|
-
def
|
|
312
|
+
def method_definition_owner(graph, node, current_constant, nesting)
|
|
313
|
+
return current_constant unless node.receiver
|
|
314
|
+
return current_constant if node.receiver.is_a?(Prism::SelfNode)
|
|
315
|
+
return unless constant_node?(node.receiver)
|
|
316
|
+
|
|
317
|
+
name = constant_reference_name(node.receiver)
|
|
318
|
+
graph.resolve_constant_reference(name, current_constant, lexical_nesting: nesting) if name
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def visit_call(graph, path, node, current_constant:, namespace:, nesting:, visibility: :public,
|
|
322
|
+
default_scope: :instance)
|
|
273
323
|
unless node.message
|
|
274
|
-
return visit_children(graph, path, node, current_constant: current_constant,
|
|
275
|
-
|
|
324
|
+
return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
325
|
+
nesting: nesting, visibility: visibility,
|
|
326
|
+
default_scope: default_scope)
|
|
276
327
|
end
|
|
277
328
|
|
|
278
329
|
message = node.message.to_sym
|
|
@@ -298,7 +349,7 @@ module ArchSpec
|
|
|
298
349
|
)
|
|
299
350
|
end
|
|
300
351
|
|
|
301
|
-
record_generated_methods(graph, path, node, message, current_constant, location)
|
|
352
|
+
record_generated_methods(graph, path, node, message, current_constant, location, visibility, default_scope)
|
|
302
353
|
|
|
303
354
|
if (edge_type = MIXIN_MESSAGES[message])
|
|
304
355
|
constant_arguments(node).each do |constant_name|
|
|
@@ -313,7 +364,8 @@ module ArchSpec
|
|
|
313
364
|
from_path: path,
|
|
314
365
|
from_constant: current_constant,
|
|
315
366
|
to: constant_name,
|
|
316
|
-
location: location
|
|
367
|
+
location: location,
|
|
368
|
+
lexical_nesting: nesting
|
|
317
369
|
)
|
|
318
370
|
end
|
|
319
371
|
end
|
|
@@ -338,10 +390,11 @@ module ArchSpec
|
|
|
338
390
|
)
|
|
339
391
|
end
|
|
340
392
|
|
|
341
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
393
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
|
|
394
|
+
visibility: visibility, default_scope: default_scope)
|
|
342
395
|
end
|
|
343
396
|
|
|
344
|
-
def add_constant_reference(graph, path, node, current_constant)
|
|
397
|
+
def add_constant_reference(graph, path, node, current_constant, nesting)
|
|
345
398
|
name = constant_reference_name(node)
|
|
346
399
|
return unless name
|
|
347
400
|
|
|
@@ -350,13 +403,69 @@ module ArchSpec
|
|
|
350
403
|
from_path: path,
|
|
351
404
|
from_constant: current_constant,
|
|
352
405
|
to: name,
|
|
353
|
-
location: SourceLocation.from_prism(path, node.location)
|
|
406
|
+
location: SourceLocation.from_prism(path, node.location),
|
|
407
|
+
lexical_nesting: nesting
|
|
354
408
|
)
|
|
355
409
|
end
|
|
356
410
|
|
|
357
|
-
def visit_children(graph, path, node, current_constant:, namespace:
|
|
411
|
+
def visit_children(graph, path, node, current_constant:, namespace:, nesting:, visibility: :public,
|
|
412
|
+
default_scope: :instance)
|
|
358
413
|
node.child_nodes.compact.each do |child|
|
|
359
|
-
visit(graph, path, child, current_constant: current_constant, namespace: namespace
|
|
414
|
+
visit(graph, path, child, current_constant: current_constant, namespace: namespace,
|
|
415
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Maps a visibility call to [visibility, forced_scope]. A nil forced_scope
|
|
420
|
+
# means the call follows the context (instance methods in a class body,
|
|
421
|
+
# class methods inside +class << self+); +*_class_method+ always targets
|
|
422
|
+
# class methods.
|
|
423
|
+
VISIBILITY_MODIFIERS = {
|
|
424
|
+
private: [:private, nil],
|
|
425
|
+
protected: [:protected, nil],
|
|
426
|
+
public: [:public, nil],
|
|
427
|
+
private_class_method: [:private, :class],
|
|
428
|
+
public_class_method: [:public, :class]
|
|
429
|
+
}.freeze
|
|
430
|
+
|
|
431
|
+
# The [visibility, forced_scope] a bare visibility call sets, or nil when
|
|
432
|
+
# the node is not one. Only receiverless calls count, so +obj.private+ is
|
|
433
|
+
# ignored.
|
|
434
|
+
def visibility_modifier(node)
|
|
435
|
+
return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
436
|
+
|
|
437
|
+
VISIBILITY_MODIFIERS[node.message&.to_sym]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Applies a visibility call and returns the default visibility for the
|
|
441
|
+
# statements that follow it. A bare +private+ changes that default; the
|
|
442
|
+
# inline (+private def foo+) and symbol-list (+private :foo+) forms mark
|
|
443
|
+
# only the methods they name and leave the default unchanged.
|
|
444
|
+
def apply_visibility_modifier(graph, path, constant, node, namespace, current, spec, default_scope, nesting)
|
|
445
|
+
visibility, forced_scope = spec
|
|
446
|
+
scope = forced_scope || default_scope
|
|
447
|
+
arguments = node.arguments&.arguments || []
|
|
448
|
+
definitions = arguments.select { |argument| argument.is_a?(Prism::DefNode) }
|
|
449
|
+
names = arguments.select { |argument| argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode) }
|
|
450
|
+
|
|
451
|
+
if definitions.any?
|
|
452
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
453
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
454
|
+
current
|
|
455
|
+
elsif names.any?
|
|
456
|
+
names.each { |name| constant.set_visibility(name.unescaped.to_sym, scope, visibility) }
|
|
457
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
458
|
+
nesting: nesting, visibility: current, default_scope: default_scope)
|
|
459
|
+
current
|
|
460
|
+
elsif arguments.empty? && forced_scope.nil?
|
|
461
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
462
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
463
|
+
visibility
|
|
464
|
+
else
|
|
465
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
466
|
+
nesting: nesting, visibility: forced_scope.nil? ? visibility : current,
|
|
467
|
+
default_scope: default_scope)
|
|
468
|
+
current
|
|
360
469
|
end
|
|
361
470
|
end
|
|
362
471
|
|
|
@@ -376,36 +485,51 @@ module ArchSpec
|
|
|
376
485
|
end || []
|
|
377
486
|
end
|
|
378
487
|
|
|
379
|
-
# attr_
|
|
380
|
-
# them, a class calling its own reader looks like a foreign call.
|
|
381
|
-
def record_generated_methods(graph, path, node, message, current_constant, location)
|
|
488
|
+
# attr_*, Rails attribute, and unprefixed delegate calls define methods;
|
|
489
|
+
# without them, a class calling its own reader looks like a foreign call.
|
|
490
|
+
def record_generated_methods(graph, path, node, message, current_constant, location, visibility, default_scope)
|
|
382
491
|
return unless current_constant && node.receiver.nil?
|
|
383
|
-
return unless ATTR_MESSAGES.key?(message) ||
|
|
492
|
+
return unless ATTR_MESSAGES.key?(message) || %i[attribute delegate].include?(message)
|
|
384
493
|
|
|
385
494
|
constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path }
|
|
386
495
|
return unless constant
|
|
496
|
+
return if message == :delegate && truthy_keyword_argument?(node, :prefix)
|
|
387
497
|
|
|
388
|
-
names =
|
|
389
|
-
|
|
390
|
-
|
|
498
|
+
names = generated_method_names(constant, node, message)
|
|
499
|
+
adder = default_scope == :class ? :add_class_method : :add_instance_method
|
|
391
500
|
names.each do |name|
|
|
392
|
-
kinds = ATTR_MESSAGES.fetch(message, %i[reader])
|
|
393
|
-
constant.
|
|
394
|
-
constant.
|
|
501
|
+
kinds = message == :attribute ? %i[reader writer] : ATTR_MESSAGES.fetch(message, %i[reader])
|
|
502
|
+
constant.public_send(adder, name, location: location, visibility: visibility) if kinds.include?(:reader)
|
|
503
|
+
constant.public_send(adder, :"#{name}=", location: location, visibility: visibility) if kinds.include?(:writer)
|
|
395
504
|
end
|
|
396
505
|
end
|
|
397
506
|
|
|
507
|
+
# Active Record and Active Model take one attribute name followed by an
|
|
508
|
+
# optional type, which may itself be a symbol. CurrentAttributes instead
|
|
509
|
+
# accepts any number of names, so retain every literal name there.
|
|
510
|
+
def generated_method_names(constant, node, message)
|
|
511
|
+
names = symbol_arguments(node)
|
|
512
|
+
return names unless message == :attribute
|
|
513
|
+
|
|
514
|
+
superclass = constant.superclass.to_s.sub(/\A::/, '')
|
|
515
|
+
return names if superclass == 'ActiveSupport::CurrentAttributes'
|
|
516
|
+
|
|
517
|
+
names.first(1)
|
|
518
|
+
end
|
|
519
|
+
|
|
398
520
|
def symbol_arguments(node)
|
|
399
521
|
node.arguments&.arguments&.filter_map do |argument|
|
|
400
522
|
argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
|
|
401
523
|
end || []
|
|
402
524
|
end
|
|
403
525
|
|
|
404
|
-
def
|
|
526
|
+
def truthy_keyword_argument?(node, name)
|
|
405
527
|
node.arguments&.arguments&.any? do |argument|
|
|
406
528
|
argument.is_a?(Prism::KeywordHashNode) && argument.elements.any? do |element|
|
|
407
529
|
element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
|
|
408
|
-
element.key.unescaped.to_sym == name
|
|
530
|
+
element.key.unescaped.to_sym == name &&
|
|
531
|
+
!element.value.is_a?(Prism::FalseNode) &&
|
|
532
|
+
!element.value.is_a?(Prism::NilNode)
|
|
409
533
|
end
|
|
410
534
|
end
|
|
411
535
|
end
|
|
@@ -423,7 +547,11 @@ module ArchSpec
|
|
|
423
547
|
return unless receiver.is_a?(Prism::CallNode) && receiver.message&.to_sym == :new
|
|
424
548
|
|
|
425
549
|
receiver_node = receiver.receiver
|
|
426
|
-
|
|
550
|
+
return unless constant_node?(receiver_node)
|
|
551
|
+
|
|
552
|
+
name = constant_reference_name(receiver_node)
|
|
553
|
+
return unless name
|
|
554
|
+
|
|
427
555
|
"#{name}##{node.message}"
|
|
428
556
|
end
|
|
429
557
|
|
|
@@ -431,7 +559,9 @@ module ArchSpec
|
|
|
431
559
|
# Admin::Users::RolesController, so compact paths join the namespace too.
|
|
432
560
|
def qualified_constant_name(node, namespace)
|
|
433
561
|
raw = constant_reference_name(node)
|
|
434
|
-
|
|
562
|
+
return unless raw
|
|
563
|
+
|
|
564
|
+
absolute = node.is_a?(Prism::ConstantPathNode) && node.parent.nil?
|
|
435
565
|
|
|
436
566
|
if absolute || namespace.empty?
|
|
437
567
|
raw
|
|
@@ -443,8 +573,11 @@ module ArchSpec
|
|
|
443
573
|
# nil when the path has dynamic parts (self.class::FOO): there is no
|
|
444
574
|
# static name to check against.
|
|
445
575
|
def constant_reference_name(node)
|
|
446
|
-
node
|
|
447
|
-
|
|
576
|
+
return unless constant_node?(node)
|
|
577
|
+
|
|
578
|
+
node.full_name.to_s
|
|
579
|
+
rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError,
|
|
580
|
+
Prism::ConstantPathNode::MissingNodesInConstantPathError
|
|
448
581
|
nil
|
|
449
582
|
end
|
|
450
583
|
|