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.
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubydex'
4
+ require 'set'
5
+
6
+ require_relative 'error'
7
+ require_relative 'source_location'
8
+
9
+ module ArchSpec
10
+ # Translates Rubydex's semantic index into the small graph ArchSpec's rules
11
+ # consume. Rubydex owns Ruby semantics here: declarations, constant
12
+ # resolution, ancestry and methods all come from its resolved graph. The
13
+ # Prism pass in Analyzer adds only syntax facts Rubydex does not expose.
14
+ class RubydexIndex
15
+ attr_reader :call_resolutions
16
+
17
+ def initialize(paths, syntax:, method_names: [])
18
+ @paths = paths.map { |path| File.expand_path(path) }.to_set
19
+ @syntax = syntax
20
+ @configured_method_names = method_names.to_set
21
+ @call_resolutions = {}
22
+ end
23
+
24
+ def populate(graph)
25
+ @index = ::Rubydex::Graph.new
26
+ @index.encoding = 'utf8'
27
+ @index.index_all(paths.to_a.sort)
28
+ @index.resolve
29
+
30
+ add_constants(graph)
31
+ add_methods(graph)
32
+ add_ancestry(graph)
33
+ add_constant_references(graph)
34
+ resolve_call_sites
35
+ add_diagnostics(graph)
36
+ graph
37
+ rescue ::Rubydex::Error, IOError, SystemCallError => error
38
+ raise Error, "Rubydex could not index the project: #{error.message.lines.first&.strip}"
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :paths, :syntax, :configured_method_names
44
+
45
+ def add_constants(graph)
46
+ declarations_with_workspace_definitions.each do |declaration, definition, path|
47
+ name = canonical_name(declaration.name)
48
+ next unless name
49
+
50
+ location = source_location(path, definition.location)
51
+ name = syntax.declaration_name(name, path, location)
52
+ kind = syntax.declaration_kind(name, path) || kind_of(declaration)
53
+ next unless kind
54
+
55
+ constant = graph.add_constant(
56
+ name: name,
57
+ kind: kind,
58
+ path: path,
59
+ location: location,
60
+ nesting: nesting_of(definition, path)
61
+ )
62
+ constant.superclass = syntax.superclass_override(name, path) if constant.class?
63
+ end
64
+ end
65
+
66
+ def add_methods(graph)
67
+ workspace_method_definitions.each do |declaration, definition, path|
68
+ owner = method_owner(declaration, definition, path)
69
+ next unless owner
70
+
71
+ constant = graph.constants_named(owner.name).find { |candidate| candidate.path == path }
72
+ next unless constant
73
+
74
+ location = source_location(path, definition.location)
75
+ signatures = signatures_for(definition)
76
+ alias_target = method_alias_target(definition)
77
+ method_names(declaration, definition).each do |name|
78
+ if owner.scope == :class
79
+ constant.add_class_method(
80
+ name,
81
+ location: location,
82
+ visibility: visibility_of(declaration),
83
+ signatures: signatures,
84
+ alias_target: alias_target
85
+ )
86
+ else
87
+ constant.add_instance_method(
88
+ name,
89
+ location: location,
90
+ visibility: visibility_of(declaration),
91
+ signatures: signatures,
92
+ alias_target: alias_target
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ def add_ancestry(graph)
100
+ workspace_namespaces.each do |declaration|
101
+ declaration.definitions.each do |definition|
102
+ path = path_of(definition.location)
103
+ next unless paths.include?(path)
104
+
105
+ name = canonical_name(declaration.name)
106
+ next unless name
107
+
108
+ name = syntax.declaration_name(name, path, source_location(path, definition.location))
109
+ constant = graph.constants_named(name).find { |candidate| candidate.path == path }
110
+ next unless constant
111
+
112
+ dynamic_superclass = syntax.superclass_override(name, path)
113
+ if dynamic_superclass
114
+ constant.superclass = dynamic_superclass
115
+ elsif definition.respond_to?(:superclass) && definition.superclass
116
+ target, resolved = reference_target(definition.superclass, path)
117
+ fallback = syntax.superclass_fallback(name, path)
118
+ target = fallback if fallback
119
+ constant.superclass = resolved || target
120
+ add_ancestry_edge(graph, :inherits_from, name, definition, definition.superclass, target, resolved)
121
+ end
122
+
123
+ next unless definition.respond_to?(:mixins)
124
+
125
+ definition.mixins.each do |mixin|
126
+ kind = mixin_kind(mixin)
127
+ next unless kind
128
+
129
+ reference = mixin.constant_reference
130
+ location = source_location(path, reference.location)
131
+ next unless syntax.constant_name_at(path, location)
132
+
133
+ target, resolved = reference_target(reference, path)
134
+ constant.add_mixin(kind, resolved || target)
135
+ add_ancestry_edge(graph, :"#{kind}s", name, definition, reference, target, resolved)
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ def add_ancestry_edge(graph, type, name, definition, reference, target, resolved)
142
+ path = path_of(definition.location)
143
+ graph.add_edge(
144
+ type: type,
145
+ from_path: path,
146
+ from_constant: name,
147
+ to: target,
148
+ resolved_to: resolved,
149
+ location: source_location(path, reference.location),
150
+ lexical_nesting: nesting_of(definition, path)
151
+ )
152
+ end
153
+
154
+ def add_constant_references(graph)
155
+ syntax.constant_sites.each do |site|
156
+ declaration = @index.resolve_constant(site.name, site.nesting)
157
+ resolved = semantic_name(declaration) if declaration
158
+
159
+ graph.add_edge(
160
+ type: :references_constant,
161
+ from_path: site.path,
162
+ from_constant: site.owner,
163
+ to: site.name,
164
+ resolved_to: resolved,
165
+ location: site.location,
166
+ lexical_nesting: site.nesting
167
+ )
168
+ end
169
+ end
170
+
171
+ def declarations_with_workspace_definitions
172
+ declarations.flat_map do |declaration|
173
+ kind = kind_of(declaration)
174
+ next [] unless kind
175
+
176
+ declaration.definitions.filter_map do |definition|
177
+ path = path_of(definition.location)
178
+ [declaration, definition, path] if paths.include?(path)
179
+ end
180
+ end.sort_by do |declaration, definition, path|
181
+ [path, definition.location.start_line, definition.location.start_column, declaration.name]
182
+ end
183
+ end
184
+
185
+ def workspace_method_definitions
186
+ declarations.grep(::Rubydex::Method).flat_map do |declaration|
187
+ declaration.definitions.filter_map do |definition|
188
+ path = path_of(definition.location)
189
+ [declaration, definition, path] if paths.include?(path)
190
+ end
191
+ end.sort_by do |declaration, definition, path|
192
+ [path, definition.location.start_line, definition.location.start_column, declaration.name]
193
+ end
194
+ end
195
+
196
+ def workspace_namespaces
197
+ declarations.select do |declaration|
198
+ declaration.is_a?(::Rubydex::Namespace) && !declaration.is_a?(::Rubydex::SingletonClass) &&
199
+ declaration.definitions.any? { |definition| paths.include?(path_of(definition.location)) }
200
+ end.sort_by(&:name)
201
+ end
202
+
203
+ def kind_of(declaration)
204
+ case declaration
205
+ when ::Rubydex::Class then :class
206
+ when ::Rubydex::Module then :module
207
+ when ::Rubydex::Constant, ::Rubydex::ConstantAlias then :constant
208
+ end
209
+ end
210
+
211
+ def method_owner(declaration, definition, path)
212
+ override = syntax.method_owner_override(path, source_location(path, definition.location))
213
+ return override if override
214
+
215
+ owner = declaration.owner
216
+ scope = owner.is_a?(::Rubydex::SingletonClass) ? :class : :instance
217
+ owner = attached(owner)
218
+ name = canonical_name(owner&.name)
219
+ location = source_location(path, definition.location)
220
+ syntax.method_owner(name && syntax.declaration_name(name, path, location), scope)
221
+ end
222
+
223
+ def method_names(declaration, definition)
224
+ name = declaration.unqualified_name.to_s.delete_suffix('()').to_sym
225
+ case definition
226
+ when ::Rubydex::AttrAccessorDefinition then [name, :"#{name}="]
227
+ when ::Rubydex::AttrWriterDefinition then [:"#{name}="]
228
+ else [name]
229
+ end
230
+ end
231
+
232
+ def signatures_for(definition)
233
+ return [] unless definition.respond_to?(:signatures)
234
+
235
+ definition.signatures.map do |signature|
236
+ MethodSignature.new(
237
+ signature.positional_parameters.length + signature.post_parameters.length,
238
+ signature.optional_positional_parameters.length,
239
+ !signature.rest_positional_parameter.nil?,
240
+ signature.keyword_parameters.map(&:name),
241
+ signature.optional_keyword_parameters.map(&:name),
242
+ !signature.rest_keyword_parameter.nil?,
243
+ !signature.block_parameter.nil?,
244
+ !signature.forward_parameter.nil?
245
+ )
246
+ end
247
+ end
248
+
249
+ def method_alias_target(definition)
250
+ return unless definition.is_a?(::Rubydex::MethodAliasDefinition) && definition.target
251
+
252
+ definition.target.unqualified_name.to_s.delete_suffix('()').to_sym
253
+ end
254
+
255
+ def resolve_call_sites
256
+ names = relevant_call_names
257
+ return if names.empty?
258
+
259
+ syntax.call_sites.each do |site|
260
+ next unless names.include?(site.name)
261
+
262
+ if site.receiver_name
263
+ declaration = @index.resolve_constant(site.receiver_name, site.nesting)
264
+ receiver = semantic_name(declaration) if declaration
265
+ @call_resolutions[site] = syntax.class::CallResolution.new(receiver, :class) if receiver
266
+ elsif site.receiver == :none && site.owner
267
+ @call_resolutions[site] = syntax.class::CallResolution.new(site.owner, site.scope)
268
+ end
269
+ end
270
+ end
271
+
272
+ def relevant_call_names
273
+ aliases = declarations.grep(::Rubydex::Method).each_with_object({}) do |declaration, map|
274
+ targets = declaration.definitions.filter_map { |definition| method_alias_target(definition) }
275
+ map[declaration.unqualified_name.to_s.delete_suffix('()').to_sym] = targets unless targets.empty?
276
+ end
277
+
278
+ names = configured_method_names.dup
279
+ loop do
280
+ added = aliases.filter_map do |name, targets|
281
+ name if targets.any? { |target| names.include?(target) } && !names.include?(name)
282
+ end
283
+ break if added.empty?
284
+
285
+ names.merge(added)
286
+ end
287
+ names
288
+ end
289
+
290
+ def add_diagnostics(graph)
291
+ @index.diagnostics.each do |diagnostic|
292
+ path = path_of(diagnostic.location)
293
+ next unless paths.include?(path)
294
+
295
+ graph.add_analysis_diagnostic(
296
+ rule: diagnostic.rule.rule_name,
297
+ message: diagnostic.message,
298
+ location: source_location(path, diagnostic.location)
299
+ )
300
+ end
301
+ end
302
+
303
+ def reference_target(reference, path)
304
+ written = syntax.constant_name_at(path, source_location(path, reference.location))
305
+
306
+ if reference.is_a?(::Rubydex::ResolvedConstantReference)
307
+ target = attached(reference.declaration)
308
+ name = semantic_name(target)
309
+ [written || name, name]
310
+ else
311
+ [written || reference.name.to_s.sub(/\A<(.+)>\z/, '\\1'), nil]
312
+ end
313
+ end
314
+
315
+ def mixin_kind(mixin)
316
+ case mixin
317
+ when ::Rubydex::Include then :include
318
+ when ::Rubydex::Prepend then :prepend
319
+ when ::Rubydex::Extend then :extend
320
+ end
321
+ end
322
+
323
+ def visibility_of(declaration)
324
+ declaration.respond_to?(:visibility) ? declaration.visibility.to_sym : :public
325
+ end
326
+
327
+ def attached(declaration)
328
+ declaration.is_a?(::Rubydex::SingletonClass) ? declaration.attached_class : declaration
329
+ end
330
+
331
+ def nesting_of(definition, path)
332
+ definition.lexical_nesting.filter_map do |owner|
333
+ declaration = owner.declaration
334
+ name = canonical_name(declaration&.name)
335
+ syntax.declaration_name(name, path, source_location(path, definition.location)) if name
336
+ end
337
+ end
338
+
339
+ def semantic_name(declaration, path = nil)
340
+ declaration = attached(declaration)
341
+ name = canonical_name(declaration.name)
342
+ syntax.declaration_name(name, path || workspace_definition_path(declaration)) if name
343
+ end
344
+
345
+ def workspace_definition_path(declaration)
346
+ attached(declaration).definitions.each do |definition|
347
+ path = path_of(definition.location)
348
+ return path if paths.include?(path)
349
+ end
350
+ nil
351
+ end
352
+
353
+ def declarations
354
+ @declarations ||= @index.declarations.to_a
355
+ end
356
+
357
+ def canonical_name(name)
358
+ return if name.to_s.include?('<anonymous>')
359
+
360
+ name.to_s.gsub(/::<[^>]+>/, '')
361
+ end
362
+
363
+ def source_location(path, location)
364
+ SourceLocation.new(
365
+ path,
366
+ location.start_line + 1,
367
+ location.start_column + 1,
368
+ location.end_line + 1,
369
+ location.end_column + 1
370
+ )
371
+ end
372
+
373
+ def path_of(location)
374
+ File.expand_path(location.to_file_path)
375
+ rescue ::Rubydex::Location::NotFileUriError
376
+ location.uri.to_s
377
+ end
378
+ end
379
+ end
@@ -5,11 +5,10 @@ module ArchSpec
5
5
  # Backs ArchSpec::DSL::ComponentProxy#must_be_empty. Flags every file in a
6
6
  # component that is meant to hold none.
7
7
  class MustBeEmptyRule
8
- attr_reader :source, :because
8
+ attr_reader :source
9
9
 
10
- def initialize(source, because: nil)
10
+ def initialize(source)
11
11
  @source = source.to_sym
12
- @because = because
13
12
  end
14
13
 
15
14
  def merge_key
@@ -29,7 +28,7 @@ module ArchSpec
29
28
 
30
29
  Diagnostic.new(
31
30
  rule: id,
32
- message: because ? "#{source} must stay empty: #{because}" : "#{source} must stay empty",
31
+ message: "#{source} must stay empty",
33
32
  location: SourceLocation.point(path, 1, 1),
34
33
  evidence: "#{relative} belongs to #{source}"
35
34
  )
@@ -35,7 +35,9 @@ module ArchSpec
35
35
  next if consumers.empty?
36
36
 
37
37
  target = graph.resolve_edge_constant(edge)
38
- includer = consumers.find { |name| target == name || target.start_with?("#{name}::") }
38
+ next if within_namespace?(target, edge.from_constant)
39
+
40
+ includer = consumers.find { |name| within_namespace?(target, name) }
39
41
  next unless includer
40
42
 
41
43
  Diagnostic.new(
@@ -49,6 +51,10 @@ module ArchSpec
49
51
 
50
52
  private
51
53
 
54
+ def within_namespace?(constant, namespace)
55
+ constant == namespace || constant.start_with?("#{namespace}::")
56
+ end
57
+
52
58
  # Maps each mixed-in module to the set of constants that mix it in.
53
59
  def includers_by_module(graph)
54
60
  map = Hash.new { |hash, key| hash[key] = Set.new }
@@ -2,8 +2,8 @@
2
2
 
3
3
  module ArchSpec
4
4
  module Rules
5
- # Backs ArchSpec::DSL::Context#no_cycles. Flags dependency cycles between
6
- # components, reporting each cycle once in a canonical order.
5
+ # Backs ArchSpec::DSL::Context#no_cycles. Flags each group of components
6
+ # that depend on each other, reporting the group once with an example cycle.
7
7
  class NoCyclesRule
8
8
  attr_reader :components
9
9
 
@@ -15,13 +15,24 @@ module ArchSpec
15
15
  'dependencies.no_cycles'
16
16
  end
17
17
 
18
+ # One diagnostic per strongly connected component. Every component in an
19
+ # SCC reaches every other one, so the group is the thing to break up;
20
+ # enumerating its elementary cycles instead reports the same tangle
21
+ # exponentially many times.
18
22
  def evaluate(graph)
19
- cycles(graph).map do |cycle|
20
- location = first_location_for_cycle(graph, cycle) || SourceLocation.point(graph.root, 1, 1)
23
+ locations = dependency_locations(graph)
24
+ adjacency = adjacency_for(locations)
25
+
26
+ StronglyConnectedComponents.of(adjacency).filter_map do |group|
27
+ next if group.size < 2
28
+
29
+ cycle = shortest_cycle(adjacency, group)
30
+ next unless cycle
31
+
21
32
  Diagnostic.new(
22
33
  rule: id,
23
- message: "component dependency cycle: #{cycle.join(' -> ')}",
24
- location: location,
34
+ message: message_for(group, cycle),
35
+ location: locations[[cycle[0], cycle[1]]] || SourceLocation.point(graph.root, 1, 1),
25
36
  evidence: cycle.join(' -> ')
26
37
  )
27
38
  end
@@ -29,39 +40,75 @@ module ArchSpec
29
40
 
30
41
  private
31
42
 
32
- def cycles(graph)
33
- adjacency = {}
34
- graph.component_dependency_pairs(only: components).each do |source, target|
35
- next if source == target
43
+ # Every component pair with a dependency between them, mapped to the first
44
+ # edge that creates it so a reported cycle can point at real source.
45
+ def dependency_locations(graph)
46
+ allowed = components.to_set
47
+ locations = {}
36
48
 
37
- (adjacency[source] ||= Set.new).add(target)
49
+ graph.dependency_edges.each do |edge|
50
+ sources = graph.source_components_for(edge)
51
+ sources &= allowed unless allowed.empty?
52
+ next if sources.empty?
53
+
54
+ targets = graph.target_components_for(edge)
55
+ sources.each do |source|
56
+ targets.each do |target|
57
+ locations[[source, target]] ||= edge.location unless source == target
58
+ end
59
+ end
38
60
  end
39
61
 
40
- found = Set.new
41
- result = []
62
+ locations
63
+ end
42
64
 
43
- adjacency.each_key do |start|
44
- walk(adjacency, start, start, [], found, result)
65
+ def adjacency_for(locations)
66
+ locations.each_key.with_object({}) do |(source, target), adjacency|
67
+ (adjacency[source] ||= []) << target
45
68
  end
69
+ end
46
70
 
47
- result
71
+ # The tightest loop in the group, as the first thing to break. Every
72
+ # member is a valid starting point, so canonicalize before comparing to
73
+ # keep the choice stable across runs.
74
+ def shortest_cycle(adjacency, group)
75
+ members = group.to_set
76
+
77
+ group.sort
78
+ .filter_map { |node| shortest_cycle_through(adjacency, node, members) }
79
+ .map { |cycle| canonical_cycle(cycle) }
80
+ .min_by { |cycle| [cycle.size, cycle.map(&:to_s)] }
48
81
  end
49
82
 
50
- def walk(adjacency, start, node, path, found, result)
51
- next_path = path + [node]
83
+ def shortest_cycle_through(adjacency, start, members)
84
+ previous = {}
85
+ queue = [start]
52
86
 
53
- adjacency.fetch(node, []).each do |target|
54
- if target == start
55
- cycle = canonical_cycle(next_path + [start])
56
- key = cycle.join("\0")
57
- next if found.include?(key)
87
+ until queue.empty?
88
+ node = queue.shift
89
+ adjacency.fetch(node, []).each do |target|
90
+ next unless members.include?(target)
91
+ return path_to(previous, start, node) if target == start
92
+ next if previous.key?(target)
58
93
 
59
- found.add(key)
60
- result << cycle
61
- elsif !path.include?(target)
62
- walk(adjacency, start, target, next_path, found, result)
94
+ previous[target] = node
95
+ queue << target
63
96
  end
64
97
  end
98
+
99
+ nil
100
+ end
101
+
102
+ def path_to(previous, start, node)
103
+ path = [node]
104
+ path.unshift(previous[path.first]) while previous.key?(path.first)
105
+ path + [start]
106
+ end
107
+
108
+ def message_for(group, cycle)
109
+ return "component dependency cycle: #{cycle.join(' -> ')}" if cycle.size - 1 == group.size
110
+
111
+ "component dependency cycle among #{group.size} components: #{group.sort.join(', ')}"
65
112
  end
66
113
 
67
114
  def canonical_cycle(cycle)
@@ -70,13 +117,63 @@ module ArchSpec
70
117
  canonical = rotations.min_by { |rotation| rotation.map(&:to_s) }
71
118
  canonical + [canonical.first]
72
119
  end
120
+ end
121
+
122
+ # Tarjan's algorithm: the components that mutually reach each other, in one
123
+ # pass over the graph.
124
+ class StronglyConnectedComponents
125
+ def self.of(adjacency)
126
+ new(adjacency).components
127
+ end
128
+
129
+ def initialize(adjacency)
130
+ @adjacency = adjacency
131
+ @index = 0
132
+ @indexes = {}
133
+ @lowlinks = {}
134
+ @stack = []
135
+ @on_stack = Set.new
136
+ @found = []
137
+ end
138
+
139
+ def components
140
+ @adjacency.each_key { |node| visit(node) unless @indexes.key?(node) }
141
+ @found
142
+ end
143
+
144
+ private
145
+
146
+ def visit(node)
147
+ @indexes[node] = @lowlinks[node] = @index
148
+ @index += 1
149
+ @stack.push(node)
150
+ @on_stack.add(node)
151
+
152
+ @adjacency.fetch(node, []).each { |target| follow(node, target) }
153
+
154
+ @found << pop_component(node) if @lowlinks[node] == @indexes[node]
155
+ end
156
+
157
+ def follow(node, target)
158
+ if @indexes.key?(target)
159
+ @lowlinks[node] = [@lowlinks[node], @indexes[target]].min if @on_stack.include?(target)
160
+ else
161
+ visit(target)
162
+ @lowlinks[node] = [@lowlinks[node], @lowlinks[target]].min
163
+ end
164
+ end
165
+
166
+ def pop_component(root)
167
+ component = []
168
+
169
+ loop do
170
+ node = @stack.pop
171
+ @on_stack.delete(node)
172
+ component << node
173
+ break if node == root
174
+ end
73
175
 
74
- def first_location_for_cycle(graph, cycle)
75
- source, target = cycle
76
- graph.dependency_edges.find do |edge|
77
- graph.source_components_for(edge).include?(source) &&
78
- graph.target_components_for(edge).include?(target)
79
- end&.location
176
+ component
80
177
  end
81
178
  end
82
179
  end