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.
@@ -3,13 +3,53 @@
3
3
  require 'pathname'
4
4
  require 'set'
5
5
 
6
- require_relative 'value_object'
7
-
8
6
  module ArchSpec
9
- ParseError = ValueObject.define(:message, :location)
10
- MethodDefinition = ValueObject.define(:owner, :name, :scope, :location, :visibility)
7
+ ParseError = Data.define(:message, :location)
8
+ AnalysisDiagnostic = Data.define(:rule, :message, :location)
9
+ MethodSignature = Data.define(
10
+ :required,
11
+ :optional,
12
+ :rest,
13
+ :keywords,
14
+ :optional_keywords,
15
+ :keyword_rest,
16
+ :block,
17
+ :forward
18
+ ) do
19
+ def accepts_arity?(arity)
20
+ return true if forward
21
+
22
+ maximum = rest ? Float::INFINITY : required + optional
23
+ arity >= required && arity <= maximum
24
+ end
25
+
26
+ def accepts_keywords?(names)
27
+ return true if forward
11
28
 
12
- Suppression = ValueObject.define(:rule, :start_line, :end_line, :reason) do
29
+ names = names.to_set
30
+ return false unless keywords.all? { |name| names.include?(name) }
31
+ return true if keyword_rest
32
+
33
+ names.all? { |name| keywords.include?(name) || optional_keywords.include?(name) }
34
+ end
35
+
36
+ def describe
37
+ positional =
38
+ if rest
39
+ "#{required}+ positional"
40
+ elsif optional.positive?
41
+ "#{required}..#{required + optional} positional"
42
+ else
43
+ "#{required} positional"
44
+ end
45
+ named = (keywords.map { |name| "#{name}:" } + optional_keywords.map { |name| "#{name}:?" }).join(', ')
46
+ [positional, ("keywords #{named}" unless named.empty?), ('**keywords' if keyword_rest), ('&block' if block),
47
+ ('forwarding' if forward)].compact.join(', ')
48
+ end
49
+ end
50
+ MethodDefinition = Data.define(:owner, :name, :scope, :location, :visibility, :signatures, :alias_target)
51
+
52
+ Suppression = Data.define(:rule, :start_line, :end_line, :reason) do
13
53
  def matches?(diagnostic)
14
54
  (rule.nil? || rule == diagnostic.rule) &&
15
55
  diagnostic.location.line >= start_line &&
@@ -31,13 +71,14 @@ module ArchSpec
31
71
  class ConstantNode
32
72
  attr_reader :name, :kind, :path, :location, :instance_methods, :class_methods, :method_definitions, :mixins,
33
73
  :nesting
34
- attr_accessor :superclass
74
+ attr_accessor :superclass, :namespace_only
35
75
 
36
- def initialize(name:, kind:, path:, location:, nesting: [])
76
+ def initialize(name:, kind:, path:, location:, nesting: [], namespace_only: false)
37
77
  @name = name
38
78
  @kind = kind
39
79
  @path = path
40
80
  @location = location
81
+ @namespace_only = namespace_only
41
82
  @nesting = Array(nesting).dup.freeze
42
83
  @instance_methods = Set.new
43
84
  @class_methods = Set.new
@@ -57,14 +98,18 @@ module ArchSpec
57
98
  kind == :module
58
99
  end
59
100
 
60
- def add_instance_method(name, location:, visibility: :public)
101
+ def add_instance_method(name, location:, visibility: :public, signatures: [], alias_target: nil)
61
102
  instance_methods.add(name.to_sym)
62
- method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location, visibility)
103
+ method_definitions << MethodDefinition.new(
104
+ self.name, name.to_sym, :instance, location, visibility, signatures, alias_target
105
+ )
63
106
  end
64
107
 
65
- def add_class_method(name, location:, visibility: :public)
108
+ def add_class_method(name, location:, visibility: :public, signatures: [], alias_target: nil)
66
109
  class_methods.add(name.to_sym)
67
- method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location, visibility)
110
+ method_definitions << MethodDefinition.new(
111
+ self.name, name.to_sym, :class, location, visibility, signatures, alias_target
112
+ )
68
113
  end
69
114
 
70
115
  def add_mixin(kind, name)
@@ -81,7 +126,7 @@ module ArchSpec
81
126
  end
82
127
  end
83
128
 
84
- Edge = ValueObject.define(
129
+ Edge = Data.define(
85
130
  :type,
86
131
  :from_path,
87
132
  :from_constant,
@@ -89,7 +134,11 @@ module ArchSpec
89
134
  :location,
90
135
  :confidence,
91
136
  :receiver,
92
- :lexical_nesting
137
+ :lexical_nesting,
138
+ :resolved_to,
139
+ :resolved_receiver,
140
+ :receiver_scope,
141
+ :resolved_method
93
142
  ) do
94
143
  VERBS = {
95
144
  references_constant: 'references',
@@ -151,7 +200,7 @@ module ArchSpec
151
200
 
152
201
  RESOLVED_ROOTS = %w[Object BasicObject].freeze
153
202
 
154
- attr_reader :root, :files, :constants, :edges, :components
203
+ attr_reader :root, :files, :constants, :edges, :components, :analysis_diagnostics
155
204
 
156
205
  def initialize(root)
157
206
  @root = File.expand_path(root)
@@ -160,6 +209,9 @@ module ArchSpec
160
209
  @constants_by_name = Hash.new { |hash, key| hash[key] = [] }
161
210
  @edges = []
162
211
  @components = {}
212
+ @analysis_diagnostics = []
213
+ @effective_definition_cache = {}
214
+ @effective_method_cache = {}
163
215
  end
164
216
 
165
217
  def add_file(path:, parse_errors:, suppressions: [])
@@ -171,21 +223,50 @@ module ArchSpec
171
223
  )
172
224
  end
173
225
 
174
- def add_constant(name:, kind:, path:, location:, nesting: [])
226
+ def add_constant(name:, kind:, path:, location:, nesting: [], namespace_only: false)
175
227
  normalized = normalize_constant(name)
176
228
  existing = @constants_by_name[normalized].find { |constant| constant.path == path && constant.kind == kind }
177
- return existing if existing
229
+ if existing
230
+ existing.namespace_only &&= namespace_only
231
+ return existing
232
+ end
178
233
 
179
- constant = ConstantNode.new(name: normalized, kind: kind, path: path, location: location, nesting: nesting)
234
+ constant = ConstantNode.new(name: normalized, kind: kind, path: path, location: location, nesting: nesting,
235
+ namespace_only: namespace_only)
180
236
  constants << constant
181
237
  @constants_by_name[normalized] << constant
182
238
  constant
183
239
  end
184
240
 
185
241
  def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil,
186
- lexical_nesting: nil)
242
+ lexical_nesting: nil, resolved_to: nil, resolved_receiver: nil, receiver_scope: nil,
243
+ resolved_method: nil)
187
244
  nesting = lexical_nesting&.map { |name| normalize_constant(name) }&.freeze
188
- edges << Edge.new(type, from_path, from_constant, to.to_s, location, confidence, receiver, nesting)
245
+ edges << Edge.new(
246
+ type,
247
+ from_path,
248
+ from_constant,
249
+ to.to_s,
250
+ location,
251
+ confidence,
252
+ receiver,
253
+ nesting,
254
+ resolved_to,
255
+ resolved_receiver,
256
+ receiver_scope,
257
+ resolved_method
258
+ )
259
+ end
260
+
261
+ def add_analysis_diagnostic(rule:, message:, location:)
262
+ analysis_diagnostics << AnalysisDiagnostic.new(rule, message, location)
263
+ end
264
+
265
+ def set_namespace_only(name, path, value)
266
+ normalized = normalize_constant(name)
267
+ @constants_by_name[normalized].each do |constant|
268
+ constant.namespace_only = value if constant.path == path && (constant.class? || constant.module?)
269
+ end
189
270
  end
190
271
 
191
272
  def constants_named(name)
@@ -215,22 +296,36 @@ module ArchSpec
215
296
  component_specs.each do |spec|
216
297
  component = Component.new(spec.name)
217
298
  files_matched_by_pattern = Set.new
299
+ excluded_files = spec.exclude_patterns.each_with_object(Set.new) do |pattern, matches|
300
+ each_matching_file(pattern) { |path| matches.add(path) }
301
+ end
218
302
 
219
303
  spec.file_patterns.each do |pattern|
220
304
  each_matching_file(pattern) do |path|
305
+ next if excluded_files.include?(path)
306
+
221
307
  files_matched_by_pattern.add(path)
222
308
  component.add_file(path, reason: "matched file pattern #{pattern}")
223
309
  end
224
310
  end
225
311
 
226
312
  constants.each do |constant|
227
- matched_file = files_matched_by_pattern.include?(constant.path)
313
+ matched_file = files_matched_by_pattern.include?(constant.path) && !defers_to_real_definition?(constant)
228
314
  matched_constant = spec.matches_constant?(constant.name)
229
- next unless matched_file || matched_constant
230
-
231
- component.add_file(constant.path, reason: "defines #{constant.name}") if matched_constant
315
+ matched_ancestor = spec.matching_ancestor(constant.name, self)
316
+ next unless matched_file || matched_constant || matched_ancestor
317
+
318
+ component.add_file(constant.path, reason: "defines #{constant.name}") if matched_constant || matched_ancestor
319
+ reason =
320
+ if matched_file
321
+ 'defined in matched file'
322
+ elsif matched_ancestor
323
+ "descends from #{matched_ancestor}"
324
+ else
325
+ 'matched namespace/constant selector'
326
+ end
232
327
  component.add_constant(constant.name, path: constant.path,
233
- reason: matched_file ? 'defined in matched file' : 'matched namespace/constant selector')
328
+ reason: reason)
234
329
  end
235
330
 
236
331
  @components[component.name] = component
@@ -296,69 +391,137 @@ module ArchSpec
296
391
  # appeared. This matters for compact class declarations and superclass
297
392
  # expressions, where inferring scope from the finished class name is wrong.
298
393
  def resolve_edge_constant(edge)
394
+ return normalize_constant(edge.resolved_to) if edge.resolved_to
395
+
299
396
  resolve_constant_reference(edge.to, edge.from_constant, lexical_nesting: edge.lexical_nesting)
300
397
  end
301
398
 
302
- # Instance methods a constant responds to, walking resolvable superclasses
303
- # and include/prepend mixins. Returns [methods, unresolved ancestor names];
304
- # a non-empty second element means the answer is incomplete.
305
- def effective_instance_methods(name, visited = Set.new)
399
+ # Every resolvable ancestor in Ruby lookup order, plus names whose
400
+ # declarations are outside the analyzed source. The scope controls whether
401
+ # mixins come from include/prepend or extend; superclass traversal applies
402
+ # to both sides.
403
+ def ancestor_names(name, scope: :instance, visited: Set.new)
306
404
  normalized = normalize_constant(name)
307
- return [Set.new, Set.new] if visited.include?(normalized)
308
-
309
- visited.add(normalized)
310
- return [Set.new, Set.new] if RESOLVED_ROOTS.include?(normalized)
405
+ return [Set.new, Set.new] if visited.include?(normalized) || RESOLVED_ROOTS.include?(normalized)
311
406
 
407
+ visited = visited.dup.add(normalized)
312
408
  nodes = constants_named(normalized)
313
409
  return [Set.new, Set[normalized]] if nodes.empty?
314
410
 
315
- methods = Set.new
411
+ ancestors = Set.new
316
412
  unresolved = Set.new
317
-
318
413
  nodes.each do |node|
319
- methods.merge(node.instance_methods)
320
-
321
- mixins = node.mixins[:include].to_a + node.mixins[:prepend].to_a
322
-
414
+ mixins = scope == :class ? node.mixins[:extend] : node.mixins[:prepend] | node.mixins[:include]
323
415
  mixins.each do |ancestor|
324
- resolved_name = resolve_constant_reference(
325
- ancestor,
326
- node.name,
327
- lexical_nesting: [node.name] + node.nesting
328
- )
329
- ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
330
- methods.merge(ancestor_methods)
331
- unresolved.merge(ancestor_unresolved)
416
+ resolved = resolve_constant_reference(ancestor, node.name, lexical_nesting: [node.name] + node.nesting)
417
+ ancestors.add(resolved)
418
+ nested, missing = ancestor_names(resolved, visited: visited)
419
+ ancestors.merge(nested)
420
+ unresolved.merge(missing)
332
421
  end
333
422
 
334
- if node.superclass
335
- resolved_name = resolve_constant_reference(node.superclass, node.name, lexical_nesting: node.nesting)
336
- ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
337
- methods.merge(ancestor_methods)
338
- unresolved.merge(ancestor_unresolved)
339
- end
423
+ next unless node.superclass
424
+
425
+ resolved = resolve_constant_reference(node.superclass, node.name, lexical_nesting: node.nesting)
426
+ ancestors.add(resolved)
427
+ nested, missing = ancestor_names(resolved, scope: scope, visited: visited)
428
+ ancestors.merge(nested)
429
+ unresolved.merge(missing)
340
430
  end
341
431
 
342
- [methods, unresolved]
432
+ [ancestors, unresolved]
343
433
  end
344
434
 
345
- def component_dependency_pairs(only: nil)
346
- allowed_sources = Array(only).compact.map(&:to_sym).to_set
347
- pairs = Set.new
435
+ def effective_instance_methods(name)
436
+ effective_methods(name, :instance)
437
+ end
348
438
 
349
- dependency_edges.each do |edge|
350
- source_components = source_components_for(edge)
351
- source_components &= allowed_sources unless allowed_sources.empty?
352
- next if source_components.empty?
439
+ def effective_class_methods(name)
440
+ effective_methods(name, :class)
441
+ end
353
442
 
354
- target_components_for(edge).each do |target|
355
- source_components.each do |source|
356
- pairs.add([source, target]) unless source == target
357
- end
443
+ # Method definitions visible on a constant, including resolvable ancestry.
444
+ # Extended modules contribute their instance methods to the class side.
445
+ def effective_method_definitions(name, scope, visited = Set.new)
446
+ normalized = normalize_constant(name)
447
+ key = [normalized, scope]
448
+ cached = @effective_definition_cache[key]
449
+ return cached if visited.empty? && cached
450
+
451
+ root = visited.empty?
452
+ return [[], Set.new] if visited.include?(key)
453
+
454
+ visited = visited.dup.add(key)
455
+ return [[], Set.new] if RESOLVED_ROOTS.include?(normalized)
456
+
457
+ nodes = constants_named(normalized)
458
+ return [[], Set[normalized]] if nodes.empty?
459
+
460
+ definitions = []
461
+ visible_names = Set.new
462
+ unresolved = Set.new
463
+
464
+ if scope == :instance
465
+ append_mixin_definitions(nodes, :prepend, definitions, visible_names, unresolved, visited)
466
+ end
467
+
468
+ own = nodes.flat_map { |node| node.method_definitions.select { |definition| definition.scope == scope } }
469
+ append_visible_definitions(definitions, visible_names, own)
470
+
471
+ mixin_kind = scope == :class ? :extend : :include
472
+ append_mixin_definitions(nodes, mixin_kind, definitions, visible_names, unresolved, visited)
473
+ append_superclass_definitions(nodes, scope, definitions, visible_names, unresolved, visited)
474
+
475
+ result = [definitions, unresolved]
476
+ @effective_definition_cache[key] = result if root
477
+ result
478
+ end
479
+
480
+ def resolve_method_alias(owner, name, scope, visited = Set.new)
481
+ name = name.to_sym
482
+ return if visited.include?(name)
483
+
484
+ definitions, = effective_method_definitions(owner, scope)
485
+ targets = definitions.filter_map do |definition|
486
+ definition.alias_target if definition.name == name
487
+ end.uniq
488
+ return unless targets.one?
489
+
490
+ target = targets.first
491
+ resolve_method_alias(owner, target, scope, visited.dup.add(name)) || target
492
+ end
493
+
494
+ def effective_methods(name, scope)
495
+ key = [normalize_constant(name), scope]
496
+ @effective_method_cache[key] ||= begin
497
+ definitions, unresolved = effective_method_definitions(name, scope)
498
+ [definitions.map(&:name).to_set, unresolved]
499
+ end
500
+ end
501
+
502
+ def incoming_dependency_edges(name)
503
+ normalized = normalize_constant(name)
504
+ dependency_edges.select { |edge| resolve_edge_constant(edge) == normalized }
505
+ end
506
+
507
+ def analysis_census(path: nil)
508
+ scoped_edges = path ? edges.select { |edge| edge.from_path == path } : edges
509
+ scoped_diagnostics =
510
+ if path
511
+ analysis_diagnostics.select { |diagnostic| diagnostic.location.path == path }
512
+ else
513
+ analysis_diagnostics
358
514
  end
515
+ unresolved = scoped_edges.select { |edge| DEPENDENCY_EDGE_TYPES.include?(edge.type) }.count do |edge|
516
+ constants_named(resolve_edge_constant(edge)).empty?
359
517
  end
360
518
 
361
- pairs
519
+ {
520
+ unresolved_constants: unresolved,
521
+ dynamic_features: scoped_edges.count { |edge| edge.type == :dynamic_feature },
522
+ unknown_receivers: scoped_edges.count { |edge| edge.type == :calls_named_method && edge.receiver == :other },
523
+ rubydex_diagnostics: scoped_diagnostics.group_by(&:rule).transform_values(&:size).sort.to_h
524
+ }
362
525
  end
363
526
 
364
527
  def component_assignment_reasons_for_path(path)
@@ -392,6 +555,44 @@ module ArchSpec
392
555
 
393
556
  private
394
557
 
558
+ def append_mixin_definitions(nodes, kind, definitions, visible_names, unresolved, visited)
559
+ mixins = nodes.flat_map do |node|
560
+ node.mixins[kind].map { |ancestor| [node, ancestor] }
561
+ end
562
+
563
+ mixins.reverse_each do |node, ancestor|
564
+ resolved_name = resolve_constant_reference(
565
+ ancestor,
566
+ node.name,
567
+ lexical_nesting: [node.name] + node.nesting
568
+ )
569
+ inherited, missing = effective_method_definitions(resolved_name, :instance, visited)
570
+ append_visible_definitions(definitions, visible_names, inherited)
571
+ unresolved.merge(missing)
572
+ end
573
+ end
574
+
575
+ def append_superclass_definitions(nodes, scope, definitions, visible_names, unresolved, visited)
576
+ nodes.filter_map(&:superclass).uniq.each do |superclass|
577
+ node = nodes.find { |candidate| candidate.superclass == superclass }
578
+ resolved_name = resolve_constant_reference(superclass, node.name, lexical_nesting: node.nesting)
579
+ inherited, missing = effective_method_definitions(resolved_name, scope, visited)
580
+ append_visible_definitions(definitions, visible_names, inherited)
581
+ unresolved.merge(missing)
582
+ end
583
+ end
584
+
585
+ def append_visible_definitions(definitions, visible_names, candidates)
586
+ names = candidates.map(&:name).to_set - visible_names
587
+ definitions.concat(candidates.select { |definition| names.include?(definition.name) })
588
+ visible_names.merge(names)
589
+ end
590
+
591
+ def defers_to_real_definition?(constant)
592
+ constant.namespace_only &&
593
+ @constants_by_name[constant.name].any? { |other| !other.namespace_only }
594
+ end
595
+
395
596
  def each_matching_file(pattern)
396
597
  glob = File.absolute_path(pattern, root)
397
598
  Dir.glob(glob).sort.each do |path|