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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 583efdae4f510098da5807cb0d7f509892851f5f26766e705dd6354c1e6b1748
4
- data.tar.gz: 5523396837663f507b4b474bcc1fd2a803f59f60910047b1dc9e910b1b46fde5
3
+ metadata.gz: b5ac200fd85432184c5043ce7540e45cb3bcc0013a37ecd77d07c34aca51bfd8
4
+ data.tar.gz: 5d3e6b1558c796c6784505b9637b5fa58fb0da9d7b85fd30e71b7a26689da471
5
5
  SHA512:
6
- metadata.gz: 4761e204b83d63afcaa16c823aa47ba6c39653990d30c30dadd94e0698ffc0dc8566cb0740125c9948c630ab983183b5907828691a55d36a66f314df5f35ea8b
7
- data.tar.gz: d5e8219c53e25cf060e78d74f8e23c75d7fc6204451ee1faf4a7f1ecac8caf53ce02b27d13bb194ca83810d7874e720385ec9daa31508d6840f9bb068c00ba88
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.can_use :models, :services
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, and event-driven bundles
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-baseline
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
- ## Baselines and Suppressions
194
+ ## Todo and Suppressions
183
195
 
184
- Use a baseline when adopting ArchSpec in an existing app:
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
- baseline ".archspec_todo.yml"
200
+ todo "archspec_todo.yml"
188
201
  ```
189
202
 
190
203
  ```sh
191
- bundle exec archspec check --update-baseline
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.
@@ -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
- def visit(graph, path, node, current_constant: nil, namespace: [])
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
- constant.superclass = superclass
202
- graph.add_edge(
203
- type: :inherits_from,
204
- from_path: path,
205
- from_constant: constant.name,
206
- to: superclass,
207
- location: SourceLocation.from_prism(path, node.superclass.location)
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
- visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
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
- visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
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
- def visit_def(graph, path, node, current_constant:, namespace:)
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
- if node.receiver
231
- constant.add_class_method(node.name, location: SourceLocation.from_prism(path, node.location))
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: SourceLocation.from_prism(path, node.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
- namespace: namespace)
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: constant_reference_name(node),
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
- name = constant_node?(receiver.receiver) ? constant_reference_name(receiver.receiver) : receiver.receiver&.slice
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 || raw.include?('::') || namespace.empty?
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)