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.
- checksums.yaml +4 -4
- data/README.md +12 -6
- data/lib/archspec/analyzer.rb +639 -261
- data/lib/archspec/architectures.rb +7 -3
- data/lib/archspec/component_spec.rb +13 -2
- data/lib/archspec/diagnostic.rb +19 -3
- data/lib/archspec/dsl.rb +69 -38
- data/lib/archspec/formatters/explanation.rb +33 -5
- data/lib/archspec/formatters/json.rb +1 -0
- data/lib/archspec/formatters/text.rb +19 -1
- data/lib/archspec/model.rb +266 -65
- data/lib/archspec/rubydex_index.rb +379 -0
- data/lib/archspec/rules/component_rules.rb +3 -4
- data/lib/archspec/rules/concern_rules.rb +7 -1
- data/lib/archspec/rules/cycle_rule.rb +130 -33
- data/lib/archspec/rules/naming_rules.rb +11 -22
- data/lib/archspec/rules/protocol_rules.rb +117 -22
- data/lib/archspec/rules/reasoned.rb +37 -0
- data/lib/archspec/source_location.rb +1 -3
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +4 -2
- metadata +21 -6
- data/lib/archspec/value_object.rb +0 -46
|
@@ -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
|
|
8
|
+
attr_reader :source
|
|
9
9
|
|
|
10
|
-
def initialize(source
|
|
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:
|
|
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
|
-
|
|
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
|
|
6
|
-
#
|
|
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
|
-
|
|
20
|
-
|
|
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:
|
|
24
|
-
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
62
|
+
locations
|
|
63
|
+
end
|
|
42
64
|
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
83
|
+
def shortest_cycle_through(adjacency, start, members)
|
|
84
|
+
previous = {}
|
|
85
|
+
queue = [start]
|
|
52
86
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|