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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25ea714cd768976e860df4f0dbaa9ff477e381096f95b98787a0a645fc0cd776
4
- data.tar.gz: 527621e2bb18514433cf26ede594f2cafa2699d0d95e2d3fa4d9e8b9e4aac354
3
+ metadata.gz: 24d73489bf5bdd8a56deffac28bbcd11aab271db3281afcce4e0aa21c93de28b
4
+ data.tar.gz: 9c6303777aededa6ce4f7780943a5a7257b33cbef978e50369296d1a4d0b0a8f
5
5
  SHA512:
6
- metadata.gz: f9c83e97692a44bbf72391593e695bc4c8a7859d148508f4cb8cf490a55e51a8de98d0df8ab0d37bf8bf13fcdb3e4707f9325850a39b9a33ea678a0d0aa7f3b2
7
- data.tar.gz: ec19d8b85144cee92cd7bd2c968d848aef39feccdda87b56849dab7ac706543fec8041bee524ab0c5532193b423442f6ef3eec00d6d52f6301ec3e960b5cf1b8
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.can_use :models, :services
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, and event-driven bundles
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] app/models/user.rb:2:3
182
- models must not depend on controllers
183
- evidence: User references_constant UsersController
184
- confidence: high
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
@@ -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
- active[rule] << [line + 1, reason]
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
- namespace: constant.name.split('::'))
209
+ visit(graph, path, node.superclass, current_constant: constant.name, namespace: namespace,
210
+ nesting: nesting)
229
211
  end
230
212
  end
231
213
 
232
- visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
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
- visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
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
- if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
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
- if node.receiver
252
- constant.add_class_method(node.name, location: SourceLocation.from_prism(path, node.location))
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: SourceLocation.from_prism(path, node.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 visit_call(graph, path, node, current_constant:, namespace:)
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
- namespace: namespace)
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_* and unprefixed delegate calls define instance methods; without
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) || message == :delegate
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 = symbol_arguments(node)
389
- return if message == :delegate && keyword_argument?(node, :prefix)
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.add_instance_method(name, location: location) if kinds.include?(:reader)
394
- constant.add_instance_method(:"#{name}=", location: location) if kinds.include?(:writer)
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 keyword_argument?(node, name)
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
- name = (constant_reference_name(receiver_node) if constant_node?(receiver_node)) || receiver_node&.slice
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
- absolute = node.respond_to?(:full_name_parts) && node.full_name_parts.first == :""
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.full_name.to_s.sub(/\A::/, '')
447
- rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
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