archspec 1.0.1 → 1.1.0.rc2

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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'prism'
4
+ require 'set'
4
5
 
5
6
  module ArchSpec
6
7
  module Analyzer
@@ -9,18 +10,23 @@ module ArchSpec
9
10
  def analyze(definition, root:)
10
11
  root = File.expand_path(root)
11
12
  graph = Graph.new(root)
13
+ paths = ruby_files(definition, root)
14
+ syntax = SyntaxOverlay.new
12
15
 
13
- ruby_files(definition, root).each do |path|
16
+ paths.each do |path|
14
17
  result = Prism.parse_file(path)
15
18
  graph.add_file(
16
19
  path: path,
17
20
  parse_errors: parse_errors_for(path, result.errors),
18
21
  suppressions: suppressions_for(result.comments)
19
22
  )
20
-
21
- SourceVisitor.visit(graph, path, result.value) if result.value
23
+ syntax.scan(path, result.value) if result.value
22
24
  end
23
25
 
26
+ method_names = definition.rules.grep(Rules::CannotCallRule).flat_map(&:method_names)
27
+ index = RubydexIndex.new(paths, syntax: syntax, method_names: method_names)
28
+ index.populate(graph)
29
+ syntax.apply(graph, call_resolutions: index.call_resolutions)
24
30
  graph.assign_components(definition.component_specs.values)
25
31
  graph
26
32
  end
@@ -81,8 +87,6 @@ module ArchSpec
81
87
  when 'next-line'
82
88
  suppressions << Suppression.new(rule, line + 1, line + 1, reason)
83
89
  else
84
- # The block form covers its own line too, so a trailing
85
- # `# archspec:disable RULE` works like RuboCop's.
86
90
  active[rule] << [line, reason]
87
91
  end
88
92
  elsif (match = text.match(ENABLE_PATTERN))
@@ -116,8 +120,28 @@ module ArchSpec
116
120
  end
117
121
  end
118
122
 
119
- module SourceVisitor
120
- extend self
123
+ # Rubydex supplies the semantic graph. This pass records the few facts
124
+ # whose syntax matters to ArchSpec's rules or that Rubydex does not model.
125
+ class SyntaxOverlay
126
+ Owner = Data.define(:name, :scope)
127
+ ConstantSite = Data.define(:path, :name, :owner, :nesting, :location)
128
+ CallSite = Data.define(:path, :name, :owner, :receiver, :receiver_name, :scope, :nesting, :location)
129
+ CallResolution = Data.define(:receiver, :scope)
130
+ GeneratedMethod = Data.define(:path, :owner, :message, :node, :location, :visibility, :scope)
131
+ MethodOwner = Data.define(:path, :location, :owner)
132
+ DeclarationName = Data.define(:path, :raw, :qualified, :location)
133
+ DeclarationFallback = Data.define(
134
+ :path,
135
+ :name,
136
+ :kind,
137
+ :location,
138
+ :nesting,
139
+ :superclass,
140
+ :superclass_location,
141
+ :dynamic_superclass
142
+ )
143
+ MethodFallback = Data.define(:path, :owner, :name, :scope, :location, :visibility, :signatures)
144
+ MixinFallback = Data.define(:path, :owner, :message, :target, :location, :nesting)
121
145
 
122
146
  DYNAMIC_MESSAGES = %i[
123
147
  class_eval
@@ -131,392 +155,712 @@ module ArchSpec
131
155
  send
132
156
  ].freeze
133
157
 
134
- MIXIN_MESSAGES = {
158
+ GENERATED_METHOD_MACROS = {
159
+ attr_reader: %i[reader],
160
+ attr_writer: %i[writer],
161
+ attr_accessor: %i[reader writer],
162
+ attribute: %i[reader writer],
163
+ delegate: %i[reader],
164
+ alias_attribute: %i[reader writer],
165
+ enum: %i[reader writer],
166
+ store_accessor: %i[reader writer],
167
+ belongs_to: %i[reader writer],
168
+ has_one: %i[reader writer],
169
+ has_many: %i[reader writer],
170
+ has_and_belongs_to_many: %i[reader writer]
171
+ }.freeze
172
+
173
+ SINGLE_NAME_MACROS = %i[
174
+ attribute
175
+ alias_attribute
176
+ enum
177
+ belongs_to
178
+ has_one
179
+ has_many
180
+ has_and_belongs_to_many
181
+ ].freeze
182
+
183
+ SINGULAR_ASSOCIATION_MACROS = %i[belongs_to has_one].freeze
184
+ SINGULAR_ASSOCIATION_HELPERS = ['build_%s', 'create_%s', 'create_%s!', 'reload_%s'].freeze
185
+
186
+ BUILDER_KINDS = {
187
+ %w[Class new] => :class,
188
+ %w[Struct new] => :class,
189
+ %w[Data define] => :class,
190
+ %w[Module new] => :module
191
+ }.freeze
192
+
193
+ MIXIN_EDGE_TYPES = {
135
194
  include: :includes,
136
195
  prepend: :prepends,
137
196
  extend: :extends
138
197
  }.freeze
139
198
 
140
- ATTR_MESSAGES = {
141
- attr_reader: %i[reader],
142
- attr_writer: %i[writer],
143
- attr_accessor: %i[reader writer]
199
+ VISIBILITY_MODIFIERS = {
200
+ private: [:private, nil],
201
+ protected: [:protected, nil],
202
+ public: [:public, nil],
203
+ private_class_method: [:private, :class],
204
+ public_class_method: [:public, :class]
144
205
  }.freeze
145
206
 
146
- def visit(graph, path, node, current_constant: nil, namespace: [], nesting: [], visibility: :public,
147
- default_scope: :instance)
207
+ attr_reader :constant_sites, :call_sites
208
+
209
+ def initialize
210
+ @constant_sites = []
211
+ @constant_sites_by_path = Hash.new { |hash, key| hash[key] = [] }
212
+ @call_sites = []
213
+ @generated_methods = []
214
+ @declaration_fallbacks = []
215
+ @method_fallbacks = []
216
+ @mixin_fallbacks = []
217
+ @extra_edges = []
218
+ @namespace_flags = Hash.new { |hash, key| hash[key] = [] }
219
+ @declaration_kinds = {}
220
+ @declaration_names = []
221
+ @superclass_overrides = {}
222
+ @superclass_fallbacks = {}
223
+ @method_owner_overrides = []
224
+ end
225
+
226
+ def scan(path, node)
227
+ visit(File.expand_path(path), node)
228
+ end
229
+
230
+ def apply(graph, call_resolutions: {})
231
+ @declaration_fallbacks.each do |fallback|
232
+ constant = graph.constants_named(fallback.name).find { |candidate| candidate.path == fallback.path }
233
+ unless constant
234
+ constant = graph.add_constant(
235
+ name: fallback.name,
236
+ kind: fallback.kind,
237
+ path: fallback.path,
238
+ location: fallback.location,
239
+ nesting: fallback.nesting
240
+ )
241
+ constant.superclass = fallback.superclass if constant.class?
242
+ end
243
+ add_fallback_ancestry(graph, fallback)
244
+ end
245
+ apply_method_fallbacks(graph)
246
+ apply_mixin_fallbacks(graph)
247
+ @namespace_flags.each do |(name, path), flags|
248
+ graph.set_namespace_only(name, path, flags.all?)
249
+ end
250
+ apply_generated_methods(graph)
251
+ @extra_edges.each { |edge| graph.add_edge(**edge) }
252
+ call_sites.each do |site|
253
+ resolution = call_resolutions[site]
254
+ resolved_method = graph.resolve_method_alias(resolution.receiver, site.name, resolution.scope) if resolution
255
+ graph.add_edge(
256
+ type: :calls_named_method,
257
+ from_path: site.path,
258
+ from_constant: site.owner,
259
+ to: site.name,
260
+ location: site.location,
261
+ receiver: site.receiver,
262
+ resolved_receiver: resolution&.receiver,
263
+ receiver_scope: resolution&.scope,
264
+ resolved_method: resolved_method
265
+ )
266
+ end
267
+ end
268
+
269
+ def declaration_kind(name, path)
270
+ @declaration_kinds[[normalize(name), path]]
271
+ end
272
+
273
+ def declaration_name(name, path = nil, location = nil)
274
+ normalized = normalize(name)
275
+ return normalized unless path
276
+
277
+ candidates = @declaration_names.select do |candidate|
278
+ candidate.path == path &&
279
+ (normalized == candidate.raw || normalized.start_with?("#{candidate.raw}::"))
280
+ end
281
+ candidates.select! { |candidate| contains?(candidate.location, location) } if location
282
+ mapping = candidates.max_by { |candidate| candidate.raw.length }
283
+ mapping ? "#{mapping.qualified}#{normalized.delete_prefix(mapping.raw)}" : normalized
284
+ end
285
+
286
+ def superclass_override(name, path)
287
+ @superclass_overrides[[normalize(name), path]]
288
+ end
289
+
290
+ def superclass_fallback(name, path)
291
+ @superclass_fallbacks[[normalize(name), path]]
292
+ end
293
+
294
+ def constant_name_at(path, location)
295
+ sites = @constant_sites_by_path[path].select { |site| contains?(site.location, location) }
296
+ sites.max_by { |site| site.name.length }&.name
297
+ end
298
+
299
+ def method_owner_override(path, location)
300
+ override = @method_owner_overrides.reverse_each.find do |candidate|
301
+ candidate.path == path && contains?(candidate.location, location)
302
+ end
303
+ override&.owner
304
+ end
305
+
306
+ def method_owner(name, scope)
307
+ Owner.new(name, scope) if name
308
+ end
309
+
310
+ private
311
+
312
+ def visit(path, node, current_constant: nil, namespace: [], nesting: [], visibility: :public,
313
+ default_scope: :instance, forced_method_owner: nil)
148
314
  return unless node
149
315
 
150
316
  case node
151
317
  when Prism::ProgramNode, Prism::StatementsNode
152
- visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
153
- visibility: visibility, default_scope: default_scope)
318
+ visit_children(path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
319
+ visibility: visibility, default_scope: default_scope,
320
+ forced_method_owner: forced_method_owner)
154
321
  when Prism::ClassNode
155
- visit_class(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
322
+ visit_class(path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
156
323
  when Prism::ModuleNode
157
- visit_module(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
324
+ visit_module(path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
158
325
  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)
326
+ visit_singleton_class(path, node, current_constant: current_constant, namespace: namespace,
327
+ nesting: nesting, visibility: visibility,
328
+ forced_method_owner: forced_method_owner)
162
329
  when Prism::DefNode
163
- visit_def(graph, path, node, current_constant: current_constant, namespace: namespace,
164
- nesting: nesting, visibility: visibility, default_scope: default_scope)
330
+ visit_def(path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
331
+ visibility: visibility, default_scope: default_scope,
332
+ forced_method_owner: forced_method_owner)
165
333
  when Prism::CallNode
166
- visit_call(graph, path, node, current_constant: current_constant, namespace: namespace,
167
- nesting: nesting, visibility: visibility, default_scope: default_scope)
334
+ visit_call(path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
335
+ visibility: visibility, default_scope: default_scope,
336
+ forced_method_owner: forced_method_owner)
337
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
338
+ visit_constant_assignment(path, node, current_constant: current_constant, namespace: namespace,
339
+ nesting: nesting)
168
340
  when Prism::ConstantPathNode, Prism::ConstantReadNode
169
- add_constant_reference(graph, path, node, current_constant, nesting)
341
+ record_constant_site(path, node, current_constant, nesting)
170
342
  else
171
- visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
172
- visibility: visibility, default_scope: default_scope)
343
+ visit_children(path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
344
+ visibility: visibility, default_scope: default_scope,
345
+ forced_method_owner: forced_method_owner)
173
346
  end
174
347
  end
175
348
 
176
- private
177
-
178
- def visit_class(graph, path, node, current_constant:, namespace:, nesting:)
349
+ def visit_class(path, node, current_constant:, namespace:, nesting:)
179
350
  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
351
+ unless name
352
+ return visit_children(path, node, current_constant: current_constant, namespace: namespace,
353
+ nesting: nesting)
354
+ end
182
355
 
183
- constant = graph.add_constant(
184
- name: name,
185
- kind: :class,
186
- path: path,
187
- location: SourceLocation.from_prism(path, node.location),
188
- nesting: nesting
356
+ location = SourceLocation.from_prism(path, node.location)
357
+ record_namespace(
358
+ name,
359
+ path,
360
+ node.superclass.nil? && namespace_only_body?(node.body),
361
+ declared_as: constant_reference_name(node.constant_path),
362
+ location: location
363
+ )
364
+
365
+ @declaration_fallbacks << DeclarationFallback.new(
366
+ path,
367
+ name,
368
+ :class,
369
+ location,
370
+ nesting,
371
+ class_superclass(node),
372
+ node.superclass && SourceLocation.from_prism(path, node.superclass.location),
373
+ node.superclass && !constant_node?(node.superclass)
189
374
  )
190
375
 
191
376
  if node.superclass
192
- superclass = constant_reference_name(node.superclass) if constant_node?(node.superclass)
193
-
194
- if superclass
195
- constant.superclass = superclass
196
- graph.add_edge(
197
- type: :inherits_from,
198
- from_path: path,
199
- from_constant: constant.name,
200
- to: superclass,
201
- location: SourceLocation.from_prism(path, node.superclass.location),
202
- lexical_nesting: nesting
203
- )
377
+ if constant_node?(node.superclass)
378
+ @superclass_fallbacks[[normalize(name), path]] = constant_reference_name(node.superclass)
204
379
  else
205
- # Dynamic superclass (Struct.new, DelegateClass(...)): no
206
- # inherits_from edge, but constants inside still count as
207
- # references, and ancestry stays marked unresolved.
208
- constant.superclass = node.superclass.slice
209
- visit(graph, path, node.superclass, current_constant: constant.name, namespace: namespace,
210
- nesting: nesting)
380
+ @superclass_overrides[[normalize(name), path]] = node.superclass.slice
381
+ visit(path, node.superclass, current_constant: name, namespace: namespace, nesting: nesting)
211
382
  end
212
383
  end
213
384
 
214
- visit_constant_body(graph, path, constant, node.body, constant.name.split('::'),
215
- nesting: [constant.name] + nesting)
385
+ visit_constant_body(path, name, node.body, name.split('::'), nesting: [name] + nesting)
216
386
  end
217
387
 
218
- def visit_module(graph, path, node, current_constant:, namespace:, nesting:)
388
+ def visit_module(path, node, current_constant:, namespace:, nesting:)
219
389
  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
390
+ unless name
391
+ return visit_children(path, node, current_constant: current_constant, namespace: namespace,
392
+ nesting: nesting)
393
+ end
222
394
 
223
- constant = graph.add_constant(
224
- name: name,
225
- kind: :module,
226
- path: path,
227
- location: SourceLocation.from_prism(path, node.location),
228
- nesting: nesting
395
+ location = SourceLocation.from_prism(path, node.location)
396
+ record_namespace(
397
+ name,
398
+ path,
399
+ namespace_only_body?(node.body),
400
+ declared_as: constant_reference_name(node.constant_path),
401
+ location: location
229
402
  )
230
-
231
- visit_constant_body(graph, path, constant, node.body, constant.name.split('::'),
232
- nesting: [constant.name] + nesting)
403
+ @declaration_fallbacks << DeclarationFallback.new(
404
+ path,
405
+ name,
406
+ :module,
407
+ location,
408
+ nesting,
409
+ nil,
410
+ nil,
411
+ false
412
+ )
413
+ visit_constant_body(path, name, node.body, name.split('::'), nesting: [name] + nesting)
233
414
  end
234
415
 
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 }
416
+ def visit_constant_assignment(path, node, current_constant:, namespace:, nesting:)
417
+ name = assigned_constant_name(node, namespace)
418
+ unless name
419
+ return visit(path, node.value, current_constant: current_constant, namespace: namespace, nesting: nesting)
420
+ end
243
421
 
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)
422
+ if node.is_a?(Prism::ConstantPathWriteNode) && node.target.parent
423
+ visit(path, node.target.parent, current_constant: name, namespace: namespace, nesting: nesting)
249
424
  end
250
- end
251
425
 
252
- def singleton_target(expression, current_constant)
253
- return current_constant if expression.is_a?(Prism::SelfNode)
426
+ kind = builder_kind(node.value)
427
+ superclass = builder_superclass(node.value) if kind == :class
428
+ superclass_argument = node.value.arguments&.arguments&.first if kind == :class
429
+ static_superclass = superclass_argument && constant_node?(superclass_argument)
430
+ location = SourceLocation.from_prism(path, node.location)
431
+ @declaration_fallbacks << DeclarationFallback.new(
432
+ path,
433
+ name,
434
+ kind || :constant,
435
+ location,
436
+ nesting,
437
+ superclass,
438
+ static_superclass && SourceLocation.from_prism(path, superclass_argument.location),
439
+ !!(superclass && !static_superclass)
440
+ )
441
+ unless kind
442
+ return visit(path, node.value, current_constant: name, namespace: namespace, nesting: nesting)
443
+ end
254
444
 
255
- constant_reference_name(expression) if constant_node?(expression)
445
+ @declaration_kinds[[normalize(name), path]] = kind
446
+ record_namespace(name, path, false, declared_as: name, location: location)
447
+ if kind == :class
448
+ if static_superclass
449
+ @superclass_fallbacks[[normalize(name), path]] = superclass
450
+ else
451
+ record_builder_superclass(name, path, node.value)
452
+ end
453
+ end
454
+
455
+ block = node.value.block
456
+ return unless block.is_a?(Prism::BlockNode) && block.body
457
+
458
+ visit_constant_body(
459
+ path,
460
+ name,
461
+ block.body,
462
+ name.split('::'),
463
+ nesting: [name] + nesting,
464
+ forced_method_owner: name
465
+ )
256
466
  end
257
467
 
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)
468
+ def visit_singleton_class(path, node, current_constant:, namespace:, nesting:, visibility:,
469
+ forced_method_owner:)
470
+ target = singleton_target(node.expression, current_constant)
471
+ visit_constant_body(
472
+ path,
473
+ target,
474
+ node.body,
475
+ namespace,
476
+ nesting: nesting,
477
+ default_scope: :class,
478
+ forced_method_owner: forced_method_owner || target
479
+ )
480
+ end
481
+
482
+ def visit_constant_body(path, name, body, namespace, nesting:, default_scope: :instance,
483
+ forced_method_owner: nil)
265
484
  return unless body
266
485
 
267
486
  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)
487
+ return visit(path, body, current_constant: name, namespace: namespace, nesting: nesting,
488
+ default_scope: default_scope, forced_method_owner: forced_method_owner)
270
489
  end
271
490
 
272
491
  visibility = :public
273
492
  body.body.each do |statement|
274
493
  if (modifier = visibility_modifier(statement))
275
- visibility = apply_visibility_modifier(graph, path, constant, statement, namespace, visibility, modifier,
276
- default_scope, nesting)
494
+ visibility = apply_visibility_modifier(
495
+ path,
496
+ name,
497
+ statement,
498
+ namespace,
499
+ nesting,
500
+ visibility,
501
+ modifier,
502
+ default_scope,
503
+ forced_method_owner
504
+ )
277
505
  else
278
- visit(graph, path, statement, current_constant: constant.name, namespace: namespace,
279
- nesting: nesting, visibility: visibility, default_scope: default_scope)
506
+ visit(path, statement, current_constant: name, namespace: namespace, nesting: nesting,
507
+ visibility: visibility, default_scope: default_scope,
508
+ forced_method_owner: forced_method_owner)
280
509
  end
281
510
  end
282
511
  end
283
512
 
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|
288
- candidate.path == path
289
- end)
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)
293
- else
294
- constant.add_instance_method(node.name, location: location, visibility: visibility)
295
- end
513
+ def visit_def(path, node, current_constant:, namespace:, nesting:, visibility:, default_scope:,
514
+ forced_method_owner:)
515
+ owner = forced_method_owner || current_constant
516
+ if owner && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
517
+ scope = (node.receiver || default_scope == :class) ? :class : :instance
518
+ @method_fallbacks << MethodFallback.new(
519
+ path,
520
+ owner,
521
+ node.name,
522
+ scope,
523
+ SourceLocation.from_prism(path, node.location),
524
+ visibility,
525
+ prism_signatures(node)
526
+ )
527
+ end
528
+
529
+ if forced_method_owner && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
530
+ scope = (node.receiver || default_scope == :class) ? :class : :instance
531
+ owner = Owner.new(forced_method_owner, scope)
532
+ @method_owner_overrides << MethodOwner.new(
533
+ path,
534
+ SourceLocation.from_prism(path, node.location),
535
+ owner
536
+ )
296
537
  end
297
538
 
298
539
  if node.name == :method_missing
299
- graph.add_edge(
540
+ @extra_edges << {
300
541
  type: :dynamic_feature,
301
542
  from_path: path,
302
- from_constant: current_constant,
543
+ from_constant: forced_method_owner || current_constant,
303
544
  to: 'method_missing',
304
545
  location: SourceLocation.from_prism(path, node.location),
305
546
  confidence: :unknown_due_to_dynamic_feature
306
- )
547
+ }
307
548
  end
308
549
 
309
- visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
550
+ visit_children(path, node, current_constant: forced_method_owner || current_constant,
551
+ namespace: namespace, nesting: nesting, default_scope: scope,
552
+ forced_method_owner: forced_method_owner)
310
553
  end
311
554
 
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)
555
+ def visit_call(path, node, current_constant:, namespace:, nesting:, visibility:, default_scope:,
556
+ forced_method_owner:)
323
557
  unless node.message
324
- return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
325
- nesting: nesting, visibility: visibility,
326
- default_scope: default_scope)
558
+ return visit_children(path, node, current_constant: current_constant, namespace: namespace,
559
+ nesting: nesting, visibility: visibility,
560
+ default_scope: default_scope, forced_method_owner: forced_method_owner)
327
561
  end
328
562
 
329
563
  message = node.message.to_sym
330
564
  location = SourceLocation.from_prism(path, node.location)
565
+ owner = forced_method_owner || current_constant
566
+ @call_sites << CallSite.new(
567
+ path,
568
+ message,
569
+ owner,
570
+ receiver_kind(node),
571
+ constant_reference_name(node.receiver),
572
+ default_scope,
573
+ nesting,
574
+ location
575
+ )
331
576
 
332
577
  if (one_shot = instantiates_and_invokes(node))
333
- graph.add_edge(
578
+ @extra_edges << {
334
579
  type: :instantiates_and_invokes,
335
580
  from_path: path,
336
- from_constant: current_constant,
581
+ from_constant: owner,
337
582
  to: one_shot,
338
583
  location: location
339
- )
584
+ }
340
585
  end
341
586
 
342
587
  if (required = literal_require_argument(node))
343
- graph.add_edge(
588
+ @extra_edges << {
344
589
  type: message == :require_relative ? :requires_relative : :requires,
345
590
  from_path: path,
346
- from_constant: current_constant,
591
+ from_constant: owner,
347
592
  to: required,
348
593
  location: location
349
- )
594
+ }
350
595
  end
351
596
 
352
- record_generated_methods(graph, path, node, message, current_constant, location, visibility, default_scope)
353
-
354
- if (edge_type = MIXIN_MESSAGES[message])
355
- constant_arguments(node).each do |constant_name|
356
- if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
357
- candidate.path == path
358
- end)
359
- constant.add_mixin(message, constant_name)
360
- end
361
-
362
- graph.add_edge(
363
- type: edge_type,
364
- from_path: path,
365
- from_constant: current_constant,
366
- to: constant_name,
367
- location: location,
368
- lexical_nesting: nesting
369
- )
370
- end
371
- end
372
-
373
- graph.add_edge(
374
- type: :calls_named_method,
375
- from_path: path,
376
- from_constant: current_constant,
377
- to: message,
378
- location: location,
379
- receiver: receiver_kind(node)
380
- )
597
+ record_generated_method(path, node, message, owner, location, visibility, default_scope)
598
+ record_mixin_fallback(path, node, message, owner, location, nesting)
381
599
 
382
600
  if DYNAMIC_MESSAGES.include?(message)
383
- graph.add_edge(
601
+ @extra_edges << {
384
602
  type: :dynamic_feature,
385
603
  from_path: path,
386
- from_constant: current_constant,
604
+ from_constant: owner,
387
605
  to: message,
388
606
  location: location,
389
607
  confidence: :unknown_due_to_dynamic_feature
390
- )
608
+ }
391
609
  end
392
610
 
393
- visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
394
- visibility: visibility, default_scope: default_scope)
611
+ visit_children(path, node, current_constant: owner, namespace: namespace, nesting: nesting,
612
+ visibility: visibility, default_scope: default_scope,
613
+ forced_method_owner: forced_method_owner)
395
614
  end
396
615
 
397
- def add_constant_reference(graph, path, node, current_constant, nesting)
616
+ def record_constant_site(path, node, current_constant, nesting)
398
617
  name = constant_reference_name(node)
399
618
  return unless name
400
619
 
401
- graph.add_edge(
402
- type: :references_constant,
403
- from_path: path,
404
- from_constant: current_constant,
405
- to: name,
406
- location: SourceLocation.from_prism(path, node.location),
407
- lexical_nesting: nesting
620
+ site = ConstantSite.new(
621
+ path,
622
+ name,
623
+ current_constant,
624
+ nesting,
625
+ SourceLocation.from_prism(path, node.location)
408
626
  )
627
+ @constant_sites << site
628
+ @constant_sites_by_path[path] << site
409
629
  end
410
630
 
411
- def visit_children(graph, path, node, current_constant:, namespace:, nesting:, visibility: :public,
412
- default_scope: :instance)
631
+ def visit_children(path, node, current_constant:, namespace:, nesting:, visibility: :public,
632
+ default_scope: :instance, forced_method_owner: nil)
413
633
  node.child_nodes.compact.each do |child|
414
- visit(graph, path, child, current_constant: current_constant, namespace: namespace,
415
- nesting: nesting, visibility: visibility, default_scope: default_scope)
634
+ visit(path, child, current_constant: current_constant, namespace: namespace, nesting: nesting,
635
+ visibility: visibility, default_scope: default_scope,
636
+ forced_method_owner: forced_method_owner)
416
637
  end
417
638
  end
418
639
 
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
640
+ def record_namespace(name, path, namespace_only, declared_as:, location:)
641
+ @namespace_flags[[normalize(name), path]] << namespace_only
642
+ raw = normalize(declared_as)
643
+ return unless raw.include?('::') && raw != normalize(name)
644
+
645
+ @declaration_names << DeclarationName.new(path, raw, normalize(name), location)
646
+ end
647
+
648
+ def namespace_only_body?(body)
649
+ body.is_a?(Prism::StatementsNode) &&
650
+ !body.body.empty? &&
651
+ body.body.all? { |child| child.is_a?(Prism::ClassNode) || child.is_a?(Prism::ModuleNode) }
652
+ end
653
+
654
+ def assigned_constant_name(node, namespace)
655
+ return qualified_constant_name(node.target, namespace) if node.is_a?(Prism::ConstantPathWriteNode)
656
+
657
+ namespace.empty? ? node.name.to_s : "#{namespace.join('::')}::#{node.name}"
658
+ end
659
+
660
+ def builder_kind(value)
661
+ return unless value.is_a?(Prism::CallNode) && constant_node?(value.receiver)
662
+
663
+ receiver = constant_reference_name(value.receiver)&.sub(/\A::/, '')
664
+ BUILDER_KINDS[[receiver, value.message]]
665
+ end
666
+
667
+ def class_superclass(node)
668
+ return unless node.superclass
669
+ return constant_reference_name(node.superclass) if constant_node?(node.superclass)
670
+
671
+ node.superclass.slice
672
+ end
673
+
674
+ def record_builder_superclass(name, path, value)
675
+ argument = value.arguments&.arguments&.first
676
+ return if argument && constant_node?(argument)
677
+
678
+ receiver = constant_reference_name(value.receiver)&.sub(/\A::/, '')
679
+ return if receiver == 'Class'
680
+
681
+ @superclass_overrides[[normalize(name), path]] = "#{constant_reference_name(value.receiver)}.#{value.message}"
682
+ end
683
+
684
+ def builder_superclass(value)
685
+ argument = value.arguments&.arguments&.first
686
+ return constant_reference_name(argument) if argument && constant_node?(argument)
687
+
688
+ receiver = constant_reference_name(value.receiver)&.sub(/\A::/, '')
689
+ "#{constant_reference_name(value.receiver)}.#{value.message}" unless receiver == 'Class'
690
+ end
691
+
692
+ def singleton_target(expression, current_constant)
693
+ return current_constant if expression.is_a?(Prism::SelfNode)
694
+
695
+ constant_reference_name(expression) if constant_node?(expression)
696
+ end
430
697
 
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
698
  def visibility_modifier(node)
435
699
  return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
436
700
 
437
701
  VISIBILITY_MODIFIERS[node.message&.to_sym]
438
702
  end
439
703
 
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)
704
+ def apply_visibility_modifier(path, owner, node, namespace, nesting, current, spec, default_scope,
705
+ forced_method_owner)
445
706
  visibility, forced_scope = spec
446
- scope = forced_scope || default_scope
447
707
  arguments = node.arguments&.arguments || []
448
708
  definitions = arguments.select { |argument| argument.is_a?(Prism::DefNode) }
449
709
  names = arguments.select { |argument| argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode) }
450
710
 
451
711
  if definitions.any?
452
- visit(graph, path, node, current_constant: constant.name, namespace: namespace,
453
- nesting: nesting, visibility: visibility, default_scope: default_scope)
712
+ visit(path, node, current_constant: owner, namespace: namespace, nesting: nesting,
713
+ visibility: visibility, default_scope: default_scope,
714
+ forced_method_owner: forced_method_owner)
454
715
  current
455
716
  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)
717
+ set_fallback_visibility(path, owner, names, forced_scope || default_scope, visibility)
718
+ visit(path, node, current_constant: owner, namespace: namespace, nesting: nesting,
719
+ visibility: current, default_scope: default_scope,
720
+ forced_method_owner: forced_method_owner)
459
721
  current
460
722
  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)
723
+ visit(path, node, current_constant: owner, namespace: namespace, nesting: nesting,
724
+ visibility: visibility, default_scope: default_scope,
725
+ forced_method_owner: forced_method_owner)
463
726
  visibility
464
727
  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)
728
+ visit(path, node, current_constant: owner, namespace: namespace, nesting: nesting,
729
+ visibility: forced_scope.nil? ? visibility : current,
730
+ default_scope: default_scope, forced_method_owner: forced_method_owner)
468
731
  current
469
732
  end
470
733
  end
471
734
 
472
- def literal_require_argument(node)
473
- return unless %i[require require_relative].include?(node.message.to_sym)
474
- return if node.receiver
735
+ def record_generated_method(path, node, message, owner, location, visibility, scope)
736
+ return unless owner && node.receiver.nil? && GENERATED_METHOD_MACROS.key?(message)
475
737
 
476
- first_argument = node.arguments&.arguments&.first
477
- return unless first_argument.is_a?(Prism::StringNode)
738
+ @generated_methods << GeneratedMethod.new(path, owner, message, node, location, visibility, scope)
739
+ end
478
740
 
479
- first_argument.unescaped
741
+ def record_mixin_fallback(path, node, message, owner, location, nesting)
742
+ return unless MIXIN_EDGE_TYPES.key?(message)
743
+
744
+ constant_arguments(node).each do |target|
745
+ @mixin_fallbacks << MixinFallback.new(path, owner, message, target, location, nesting)
746
+ end
480
747
  end
481
748
 
482
- def constant_arguments(node)
483
- node.arguments&.arguments&.filter_map do |argument|
484
- constant_reference_name(argument) if constant_node?(argument)
485
- end || []
749
+ def apply_generated_methods(graph)
750
+ @generated_methods.each do |generated|
751
+ constant = graph.constants_named(generated.owner).find { |candidate| candidate.path == generated.path }
752
+ next unless constant
753
+ next if generated.message == :delegate && truthy_keyword_argument?(generated.node, :prefix)
754
+
755
+ generated_method_names(constant, generated.node, generated.message).each do |name|
756
+ accessors_for(generated.message, name).each do |accessor|
757
+ scope = generated.scope == :class ? :class : :instance
758
+ next if constant.method_definitions.any? do |definition|
759
+ definition.name == accessor && definition.scope == scope
760
+ end
761
+
762
+ adder = scope == :class ? :add_class_method : :add_instance_method
763
+ constant.public_send(
764
+ adder,
765
+ accessor,
766
+ location: generated.location,
767
+ visibility: generated.visibility
768
+ )
769
+ end
770
+ end
771
+ end
486
772
  end
487
773
 
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)
491
- return unless current_constant && node.receiver.nil?
492
- return unless ATTR_MESSAGES.key?(message) || %i[attribute delegate].include?(message)
774
+ def apply_method_fallbacks(graph)
775
+ @method_fallbacks.each do |fallback|
776
+ constant = graph.constants_named(fallback.owner).find { |candidate| candidate.path == fallback.path }
777
+ next unless constant
778
+ next if constant.method_definitions.any? do |definition|
779
+ definition.name == fallback.name && definition.scope == fallback.scope
780
+ end
781
+
782
+ adder = fallback.scope == :class ? :add_class_method : :add_instance_method
783
+ constant.public_send(
784
+ adder,
785
+ fallback.name,
786
+ location: fallback.location,
787
+ visibility: fallback.visibility,
788
+ signatures: fallback.signatures
789
+ )
790
+ end
791
+ end
792
+
793
+ def add_fallback_ancestry(graph, fallback)
794
+ return unless fallback.kind == :class && fallback.superclass && !fallback.dynamic_superclass
795
+ return if graph.edges.any? do |edge|
796
+ edge.type == :inherits_from && edge.from_path == fallback.path && edge.from_constant == fallback.name
797
+ end
493
798
 
494
- constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path }
495
- return unless constant
496
- return if message == :delegate && truthy_keyword_argument?(node, :prefix)
799
+ graph.add_edge(
800
+ type: :inherits_from,
801
+ from_path: fallback.path,
802
+ from_constant: fallback.name,
803
+ to: fallback.superclass,
804
+ location: fallback.superclass_location || fallback.location,
805
+ lexical_nesting: fallback.nesting
806
+ )
807
+ end
497
808
 
498
- names = generated_method_names(constant, node, message)
499
- adder = default_scope == :class ? :add_class_method : :add_instance_method
500
- names.each do |name|
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)
809
+ def apply_mixin_fallbacks(graph)
810
+ @mixin_fallbacks.each do |fallback|
811
+ type = MIXIN_EDGE_TYPES.fetch(fallback.message)
812
+ next if graph.edges.any? do |edge|
813
+ edge.type == type && edge.from_path == fallback.path && edge.from_constant == fallback.owner &&
814
+ edge.to == fallback.target
815
+ end
816
+
817
+ constant = graph.constants_named(fallback.owner).find { |candidate| candidate.path == fallback.path }
818
+ constant&.add_mixin(fallback.message, fallback.target)
819
+ graph.add_edge(
820
+ type: type,
821
+ from_path: fallback.path,
822
+ from_constant: fallback.owner,
823
+ to: fallback.target,
824
+ location: fallback.location,
825
+ lexical_nesting: fallback.nesting
826
+ )
504
827
  end
505
828
  end
506
829
 
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.
830
+ def set_fallback_visibility(path, owner, names, scope, visibility)
831
+ method_names = names.map { |name| name.unescaped.to_sym }
832
+ @method_fallbacks.map! do |fallback|
833
+ if fallback.path == path && fallback.owner == owner && fallback.scope == scope &&
834
+ method_names.include?(fallback.name)
835
+ fallback.with(visibility: visibility)
836
+ else
837
+ fallback
838
+ end
839
+ end
840
+ end
841
+
842
+ def accessors_for(message, name)
843
+ kinds = GENERATED_METHOD_MACROS.fetch(message)
844
+ accessors = []
845
+ accessors << name if kinds.include?(:reader)
846
+ accessors << :"#{name}=" if kinds.include?(:writer)
847
+ return accessors unless SINGULAR_ASSOCIATION_MACROS.include?(message)
848
+
849
+ accessors + SINGULAR_ASSOCIATION_HELPERS.map { |template| format(template, name).to_sym }
850
+ end
851
+
510
852
  def generated_method_names(constant, node, message)
511
853
  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'
854
+ return names unless SINGLE_NAME_MACROS.include?(message)
855
+ return names if message == :attribute && current_attributes?(constant)
516
856
 
517
857
  names.first(1)
518
858
  end
519
859
 
860
+ def current_attributes?(constant)
861
+ constant.superclass.to_s.sub(/\A::/, '') == 'ActiveSupport::CurrentAttributes'
862
+ end
863
+
520
864
  def symbol_arguments(node)
521
865
  node.arguments&.arguments&.filter_map do |argument|
522
866
  argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
@@ -534,6 +878,40 @@ module ArchSpec
534
878
  end
535
879
  end
536
880
 
881
+ def literal_require_argument(node)
882
+ return unless %i[require require_relative].include?(node.message.to_sym)
883
+ return if node.receiver
884
+
885
+ first_argument = node.arguments&.arguments&.first
886
+ first_argument.unescaped if first_argument.is_a?(Prism::StringNode)
887
+ end
888
+
889
+ def constant_arguments(node)
890
+ node.arguments&.arguments&.filter_map do |argument|
891
+ constant_reference_name(argument) if constant_node?(argument)
892
+ end || []
893
+ end
894
+
895
+ def prism_signatures(node)
896
+ parameters = node.parameters
897
+ return [] unless parameters
898
+
899
+ keywords = parameters.keywords
900
+ forward = parameters.keyword_rest.is_a?(Prism::ForwardingParameterNode)
901
+ [
902
+ MethodSignature.new(
903
+ parameters.requireds.length + parameters.posts.length,
904
+ parameters.optionals.length,
905
+ !parameters.rest.nil? || forward,
906
+ keywords.grep(Prism::RequiredKeywordParameterNode).map(&:name),
907
+ keywords.grep(Prism::OptionalKeywordParameterNode).map(&:name),
908
+ !parameters.keyword_rest.nil? && !forward,
909
+ !parameters.block.nil? || forward,
910
+ forward
911
+ )
912
+ ]
913
+ end
914
+
537
915
  def receiver_kind(node)
538
916
  receiver = node.receiver
539
917
  return :none if receiver.nil? || receiver.is_a?(Prism::SelfNode)
@@ -545,33 +923,20 @@ module ArchSpec
545
923
  def instantiates_and_invokes(node)
546
924
  receiver = node.receiver
547
925
  return unless receiver.is_a?(Prism::CallNode) && receiver.message&.to_sym == :new
926
+ return unless constant_node?(receiver.receiver)
548
927
 
549
- receiver_node = receiver.receiver
550
- return unless constant_node?(receiver_node)
551
-
552
- name = constant_reference_name(receiver_node)
553
- return unless name
554
-
555
- "#{name}##{node.message}"
928
+ name = constant_reference_name(receiver.receiver)
929
+ "#{name}##{node.message}" if name
556
930
  end
557
931
 
558
- # class Users::RolesController inside module Admin defines
559
- # Admin::Users::RolesController, so compact paths join the namespace too.
560
932
  def qualified_constant_name(node, namespace)
561
933
  raw = constant_reference_name(node)
562
934
  return unless raw
563
935
 
564
936
  absolute = node.is_a?(Prism::ConstantPathNode) && node.parent.nil?
565
-
566
- if absolute || namespace.empty?
567
- raw
568
- else
569
- "#{namespace.join('::')}::#{raw}"
570
- end
937
+ absolute || namespace.empty? ? raw : "#{namespace.join('::')}::#{raw}"
571
938
  end
572
939
 
573
- # nil when the path has dynamic parts (self.class::FOO): there is no
574
- # static name to check against.
575
940
  def constant_reference_name(node)
576
941
  return unless constant_node?(node)
577
942
 
@@ -584,6 +949,19 @@ module ArchSpec
584
949
  def constant_node?(node)
585
950
  node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
586
951
  end
952
+
953
+ def contains?(outer, inner)
954
+ position(outer.line, outer.column) <= position(inner.line, inner.column) &&
955
+ position(outer.end_line, outer.end_column) >= position(inner.end_line, inner.end_column)
956
+ end
957
+
958
+ def position(line, column)
959
+ (line << 32) + column
960
+ end
961
+
962
+ def normalize(name)
963
+ name.to_s.sub(/\A::/, '')
964
+ end
587
965
  end
588
966
  end
589
967
  end