archspec 0.1.0.pre1
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/exe/archspec +7 -0
- data/lib/archspec/analyzer.rb +376 -0
- data/lib/archspec/architectures.rb +196 -0
- data/lib/archspec/baseline.rb +50 -0
- data/lib/archspec/cli.rb +213 -0
- data/lib/archspec/component_spec.rb +34 -0
- data/lib/archspec/definition.rb +64 -0
- data/lib/archspec/diagnostic.rb +36 -0
- data/lib/archspec/dsl.rb +132 -0
- data/lib/archspec/evaluator.rb +28 -0
- data/lib/archspec/formatters/json.rb +18 -0
- data/lib/archspec/formatters/text.rb +26 -0
- data/lib/archspec/model.rb +278 -0
- data/lib/archspec/presets.rb +56 -0
- data/lib/archspec/rules/cycle_rule.rb +79 -0
- data/lib/archspec/rules/dependency_rules.rb +117 -0
- data/lib/archspec/rules/protocol_rules.rb +186 -0
- data/lib/archspec/rules/zeitwerk_rule.rb +25 -0
- data/lib/archspec/source_location.rb +15 -0
- data/lib/archspec/version.rb +3 -0
- data/lib/archspec.rb +34 -0
- metadata +115 -0
data/lib/archspec/dsl.rb
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
module DSL
|
|
3
|
+
module Context
|
|
4
|
+
def root(path = nil)
|
|
5
|
+
return root_path unless path
|
|
6
|
+
|
|
7
|
+
self.root_path = path.to_s
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def source(*patterns)
|
|
11
|
+
add_source_patterns(patterns)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ignore(*patterns)
|
|
15
|
+
add_ignore_patterns(patterns)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def baseline(path = ".archspec_todo.yml")
|
|
19
|
+
self.baseline_path = path.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def component(name, in: nil, namespace: nil, constants: nil)
|
|
23
|
+
add_component(
|
|
24
|
+
ComponentSpec.new(name, files: binding.local_variable_get(:in), namespace: namespace, constants: constants)
|
|
25
|
+
)
|
|
26
|
+
ComponentProxy.new(self, name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
alias layer component
|
|
30
|
+
alias role component
|
|
31
|
+
|
|
32
|
+
def preset(name, **options)
|
|
33
|
+
Presets.apply(name, self, **options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def architecture(name, **options)
|
|
37
|
+
Architectures.apply(name, self, **options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def no_cycles!(among: nil)
|
|
41
|
+
add_rule(Rules::NoCyclesRule.new(among: among))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def verify_zeitwerk_names!
|
|
45
|
+
add_rule(Rules::ZeitwerkNamingRule.new)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rule(rule)
|
|
49
|
+
add_rule(rule)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def method_missing(name, ...)
|
|
53
|
+
return ComponentProxy.new(self, name) if component?(name)
|
|
54
|
+
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def respond_to_missing?(name, include_private = false)
|
|
59
|
+
component?(name) || super
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class ComponentProxy
|
|
64
|
+
attr_reader :definition, :name
|
|
65
|
+
|
|
66
|
+
def initialize(definition, name)
|
|
67
|
+
@definition = definition
|
|
68
|
+
@name = name.to_sym
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def can_use(*targets)
|
|
72
|
+
add_rule(Rules::AllowDependenciesRule.new(name, targets))
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
alias only_depend_on can_use
|
|
77
|
+
alias must_only_depend_on can_use
|
|
78
|
+
|
|
79
|
+
def cannot_use(*targets)
|
|
80
|
+
add_rule(Rules::ForbidDependenciesRule.new(name, targets))
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cannot_call(*methods)
|
|
85
|
+
add_rule(Rules::CannotCallRule.new(name, methods))
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cannot_define(*methods)
|
|
90
|
+
add_rule(Rules::CannotDefineMethodRule.new(name, methods))
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cannot_instantiate_and_invoke
|
|
95
|
+
add_rule(Rules::CannotInstantiateAndInvokeRule.new(name))
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def cannot_reference_constants(*constants)
|
|
100
|
+
add_rule(Rules::CannotReferenceConstantsRule.new(name, constants))
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def must_implement(*methods)
|
|
105
|
+
methods.each do |method_name|
|
|
106
|
+
add_rule(Rules::MustImplementRule.new(name, method_name))
|
|
107
|
+
end
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def must_implement_one_of(*methods)
|
|
112
|
+
add_rule(Rules::MustImplementOneOfRule.new(name, methods))
|
|
113
|
+
self
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def add_rule(rule)
|
|
119
|
+
if rule.respond_to?(:merge_key)
|
|
120
|
+
existing = definition.rules.find do |candidate|
|
|
121
|
+
candidate.respond_to?(:merge_key) && candidate.merge_key == rule.merge_key
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
return existing.merge!(rule) if existing&.respond_to?(:merge!)
|
|
125
|
+
return existing if existing
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
definition.add_rule(rule)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
module Evaluator
|
|
3
|
+
extend self
|
|
4
|
+
|
|
5
|
+
def evaluate(definition, graph, baseline: Baseline.empty)
|
|
6
|
+
(parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) })
|
|
7
|
+
.reject { |diagnostic| graph.suppressed?(diagnostic) }
|
|
8
|
+
.reject { |diagnostic| baseline.include?(diagnostic) }
|
|
9
|
+
.sort_by { |diagnostic| [diagnostic.location.path, diagnostic.location.line, diagnostic.rule, diagnostic.message] }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def parser_diagnostics(graph)
|
|
15
|
+
graph.files.values.flat_map do |file|
|
|
16
|
+
file.parse_errors.map do |parse_error|
|
|
17
|
+
Diagnostic.new(
|
|
18
|
+
rule: "parser.syntax",
|
|
19
|
+
message: parse_error.message,
|
|
20
|
+
location: parse_error.location,
|
|
21
|
+
evidence: file.relative_path,
|
|
22
|
+
confidence: :high
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Formatters
|
|
5
|
+
module JSON
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
def print(output = $stdout, graph:, diagnostics:)
|
|
9
|
+
output.puts ::JSON.pretty_generate(
|
|
10
|
+
files: graph.files.size,
|
|
11
|
+
constants: graph.constants.size,
|
|
12
|
+
facts: graph.edges.size,
|
|
13
|
+
violations: diagnostics.map { |diagnostic| diagnostic.to_h(root: graph.root) }
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
module Formatters
|
|
3
|
+
module Text
|
|
4
|
+
extend self
|
|
5
|
+
|
|
6
|
+
def print(output = $stdout, graph:, diagnostics:)
|
|
7
|
+
if diagnostics.empty?
|
|
8
|
+
output.puts "ArchSpec passed: #{graph.files.size} files, #{graph.constants.size} constants, #{graph.edges.size} facts checked."
|
|
9
|
+
return
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
output.puts "#{diagnostics.size} architecture #{diagnostics.size == 1 ? "violation" : "violations"}"
|
|
13
|
+
output.puts
|
|
14
|
+
|
|
15
|
+
diagnostics.each do |diagnostic|
|
|
16
|
+
output.puts "[#{diagnostic.rule}] #{diagnostic.location.relative_path(graph.root)}:#{diagnostic.location.line}:#{diagnostic.location.column}"
|
|
17
|
+
output.puts " #{diagnostic.message}"
|
|
18
|
+
output.puts " evidence: #{diagnostic.evidence}"
|
|
19
|
+
output.puts " confidence: #{diagnostic.confidence}"
|
|
20
|
+
output.puts " id: #{diagnostic.fingerprint(root: graph.root)}"
|
|
21
|
+
output.puts
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
module ArchSpec
|
|
5
|
+
ParseError = Data.define(:message, :location)
|
|
6
|
+
MethodDefinition = Data.define(:owner, :name, :scope, :location)
|
|
7
|
+
|
|
8
|
+
Suppression = Data.define(:rule, :start_line, :end_line, :reason) do
|
|
9
|
+
def matches?(diagnostic)
|
|
10
|
+
(rule.nil? || rule == diagnostic.rule) &&
|
|
11
|
+
diagnostic.location.line >= start_line &&
|
|
12
|
+
diagnostic.location.line <= end_line
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class SourceFile
|
|
17
|
+
attr_reader :path, :relative_path, :expected_constant, :parse_errors, :suppressions
|
|
18
|
+
|
|
19
|
+
def initialize(root:, path:, expected_constant:, parse_errors:, suppressions:)
|
|
20
|
+
@path = path
|
|
21
|
+
@relative_path = Pathname(path).relative_path_from(Pathname(root)).to_s
|
|
22
|
+
@expected_constant = expected_constant
|
|
23
|
+
@parse_errors = parse_errors
|
|
24
|
+
@suppressions = suppressions
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class ConstantNode
|
|
29
|
+
attr_reader :name, :kind, :path, :location, :instance_methods, :class_methods, :method_definitions, :mixins
|
|
30
|
+
attr_accessor :superclass
|
|
31
|
+
|
|
32
|
+
def initialize(name:, kind:, path:, location:)
|
|
33
|
+
@name = name
|
|
34
|
+
@kind = kind
|
|
35
|
+
@path = path
|
|
36
|
+
@location = location
|
|
37
|
+
@instance_methods = Set.new
|
|
38
|
+
@class_methods = Set.new
|
|
39
|
+
@method_definitions = []
|
|
40
|
+
@mixins = {
|
|
41
|
+
include: Set.new,
|
|
42
|
+
prepend: Set.new,
|
|
43
|
+
extend: Set.new
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def class?
|
|
48
|
+
kind == :class
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def module?
|
|
52
|
+
kind == :module
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_instance_method(name, location:)
|
|
56
|
+
instance_methods.add(name.to_sym)
|
|
57
|
+
method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_class_method(name, location:)
|
|
61
|
+
class_methods.add(name.to_sym)
|
|
62
|
+
method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def add_mixin(kind, name)
|
|
66
|
+
mixins.fetch(kind).add(name)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
Edge = Data.define(:type, :from_path, :from_constant, :to, :location, :confidence)
|
|
71
|
+
|
|
72
|
+
class Component
|
|
73
|
+
attr_reader :name, :files, :constants, :file_reasons, :constant_reasons
|
|
74
|
+
|
|
75
|
+
def initialize(name)
|
|
76
|
+
@name = name.to_sym
|
|
77
|
+
@files = Set.new
|
|
78
|
+
@constants = Set.new
|
|
79
|
+
@file_reasons = Hash.new { |hash, key| hash[key] = Set.new }
|
|
80
|
+
@constant_reasons = Hash.new { |hash, key| hash[key] = Set.new }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def add_file(path, reason: nil)
|
|
84
|
+
files.add(path)
|
|
85
|
+
file_reasons[path].add(reason) if reason
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add_constant(name, reason: nil)
|
|
89
|
+
constants.add(name)
|
|
90
|
+
constant_reasons[name].add(reason) if reason
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Graph
|
|
95
|
+
DEPENDENCY_EDGE_TYPES = %i[
|
|
96
|
+
references_constant
|
|
97
|
+
inherits_from
|
|
98
|
+
includes
|
|
99
|
+
prepends
|
|
100
|
+
extends
|
|
101
|
+
].freeze
|
|
102
|
+
|
|
103
|
+
attr_reader :root, :files, :constants, :edges, :components
|
|
104
|
+
|
|
105
|
+
def initialize(root)
|
|
106
|
+
@root = File.expand_path(root)
|
|
107
|
+
@files = {}
|
|
108
|
+
@constants = []
|
|
109
|
+
@constants_by_name = Hash.new { |hash, key| hash[key] = [] }
|
|
110
|
+
@edges = []
|
|
111
|
+
@components = {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def add_file(path:, expected_constant:, parse_errors:, suppressions: [])
|
|
115
|
+
files[path] = SourceFile.new(
|
|
116
|
+
root: root,
|
|
117
|
+
path: path,
|
|
118
|
+
expected_constant: expected_constant,
|
|
119
|
+
parse_errors: parse_errors,
|
|
120
|
+
suppressions: suppressions
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def add_constant(name:, kind:, path:, location:)
|
|
125
|
+
normalized = normalize_constant(name)
|
|
126
|
+
existing = @constants_by_name[normalized].find { |constant| constant.path == path && constant.kind == kind }
|
|
127
|
+
return existing if existing
|
|
128
|
+
|
|
129
|
+
constant = ConstantNode.new(name: normalized, kind: kind, path: path, location: location)
|
|
130
|
+
constants << constant
|
|
131
|
+
@constants_by_name[normalized] << constant
|
|
132
|
+
constant
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high)
|
|
136
|
+
edges << Edge.new(type, from_path, from_constant, normalize_constant(to), location, confidence)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def constants_named(name)
|
|
140
|
+
@constants_by_name[normalize_constant(name)]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def constants_for_path(path)
|
|
144
|
+
constants.select { |constant| constant.path == path }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def method_definitions_for_component(name)
|
|
148
|
+
component = components.fetch(name.to_sym)
|
|
149
|
+
component.constants.flat_map { |constant_name| constants_named(constant_name) }.flat_map(&:method_definitions)
|
|
150
|
+
rescue KeyError
|
|
151
|
+
[]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def assign_components(component_specs)
|
|
155
|
+
@components = {}
|
|
156
|
+
|
|
157
|
+
component_specs.each do |spec|
|
|
158
|
+
component = Component.new(spec.name)
|
|
159
|
+
|
|
160
|
+
spec.file_patterns.each do |pattern|
|
|
161
|
+
each_matching_file(pattern) { |path| component.add_file(path, reason: "matched file pattern #{pattern}") }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
constants.each do |constant|
|
|
165
|
+
matched_file = component.files.include?(constant.path)
|
|
166
|
+
matched_constant = spec.matches_constant?(constant.name)
|
|
167
|
+
next unless matched_file || matched_constant
|
|
168
|
+
|
|
169
|
+
component.add_file(constant.path, reason: "defines #{constant.name}") if matched_constant
|
|
170
|
+
component.add_constant(constant.name, reason: matched_file ? "defined in matched file" : "matched namespace/constant selector")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
@components[component.name] = component
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def component_names_for_path(path)
|
|
178
|
+
components.values.each_with_object(Set.new) do |component, names|
|
|
179
|
+
names.add(component.name) if component.files.include?(path)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def component_names_for_constant(name)
|
|
184
|
+
normalized = normalize_constant(name)
|
|
185
|
+
|
|
186
|
+
components.values.each_with_object(Set.new) do |component, names|
|
|
187
|
+
names.add(component.name) if component.constants.include?(normalized)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def dependency_edges
|
|
192
|
+
edges.select { |edge| DEPENDENCY_EDGE_TYPES.include?(edge.type) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def target_components_for(edge)
|
|
196
|
+
return Set.new unless DEPENDENCY_EDGE_TYPES.include?(edge.type)
|
|
197
|
+
|
|
198
|
+
resolved = resolve_constant_reference(edge.to, edge.from_constant)
|
|
199
|
+
constants_named(resolved).each_with_object(Set.new) do |constant, names|
|
|
200
|
+
names.merge(component_names_for_path(constant.path))
|
|
201
|
+
names.merge(component_names_for_constant(constant.name))
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def resolve_constant_reference(name, from_constant)
|
|
206
|
+
normalized = normalize_constant(name)
|
|
207
|
+
candidates = []
|
|
208
|
+
|
|
209
|
+
if from_constant
|
|
210
|
+
namespace = normalize_constant(from_constant).split("::")
|
|
211
|
+
namespace.pop
|
|
212
|
+
|
|
213
|
+
until namespace.empty?
|
|
214
|
+
candidates << "#{namespace.join("::")}::#{normalized}"
|
|
215
|
+
namespace.pop
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
candidates << normalized
|
|
220
|
+
candidates.find { |candidate| constants_named(candidate).any? } || normalized
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def component_dependency_pairs(only: nil)
|
|
224
|
+
allowed_sources = Array(only).compact.map(&:to_sym).to_set
|
|
225
|
+
pairs = Set.new
|
|
226
|
+
|
|
227
|
+
dependency_edges.each do |edge|
|
|
228
|
+
source_components = component_names_for_path(edge.from_path)
|
|
229
|
+
source_components &= allowed_sources unless allowed_sources.empty?
|
|
230
|
+
next if source_components.empty?
|
|
231
|
+
|
|
232
|
+
target_components_for(edge).each do |target|
|
|
233
|
+
source_components.each do |source|
|
|
234
|
+
pairs.add([source, target]) unless source == target
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
pairs
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def component_assignment_reasons_for_path(path)
|
|
243
|
+
components.values.each_with_object({}) do |component, reasons|
|
|
244
|
+
next unless component.files.include?(path)
|
|
245
|
+
|
|
246
|
+
reasons[component.name] = component.file_reasons[path].to_a.sort
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def component_assignment_reasons_for_constant(name)
|
|
251
|
+
normalized = normalize_constant(name)
|
|
252
|
+
|
|
253
|
+
components.values.each_with_object({}) do |component, reasons|
|
|
254
|
+
next unless component.constants.include?(normalized)
|
|
255
|
+
|
|
256
|
+
reasons[component.name] = component.constant_reasons[normalized].to_a.sort
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def suppressed?(diagnostic)
|
|
261
|
+
files[diagnostic.location.path]&.suppressions&.any? { |suppression| suppression.matches?(diagnostic) }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private
|
|
265
|
+
|
|
266
|
+
def each_matching_file(pattern)
|
|
267
|
+
glob = File.absolute_path(pattern, root)
|
|
268
|
+
Dir.glob(glob).sort.each do |path|
|
|
269
|
+
expanded = File.expand_path(path)
|
|
270
|
+
yield expanded if files.key?(expanded)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def normalize_constant(value)
|
|
275
|
+
value.to_s.sub(/\A::/, "")
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
module Presets
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def apply(name, dsl, **options)
|
|
6
|
+
case name.to_sym
|
|
7
|
+
when :rails_way, :rails_mvc
|
|
8
|
+
rails_way(dsl, **options)
|
|
9
|
+
when :rails_strict
|
|
10
|
+
rails_strict(dsl, **options)
|
|
11
|
+
when :rails_layered
|
|
12
|
+
rails_layered(dsl, **options)
|
|
13
|
+
when :rails_hexagonal
|
|
14
|
+
rails_hexagonal(dsl, **options)
|
|
15
|
+
when :rails_clean
|
|
16
|
+
rails_clean(dsl, **options)
|
|
17
|
+
when :rails_cqrs
|
|
18
|
+
rails_cqrs(dsl, **options)
|
|
19
|
+
when :rails_event_driven
|
|
20
|
+
rails_event_driven(dsl, **options)
|
|
21
|
+
else
|
|
22
|
+
raise Error, "Unknown ArchSpec preset: #{name.inspect}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rails_way(dsl, **options)
|
|
27
|
+
Architectures.apply(:rails_mvc, dsl, **options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rails_strict(dsl, **options)
|
|
31
|
+
rails_way(dsl, **options)
|
|
32
|
+
dsl.verify_zeitwerk_names!
|
|
33
|
+
dsl.no_cycles!(among: %i[controllers models helpers mailers jobs services])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rails_layered(dsl, **options)
|
|
37
|
+
Architectures.apply(:layered, dsl, **options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def rails_hexagonal(dsl, **options)
|
|
41
|
+
Architectures.apply(:hexagonal, dsl, **options)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def rails_clean(dsl, **options)
|
|
45
|
+
Architectures.apply(:clean, dsl, **options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rails_cqrs(dsl, **options)
|
|
49
|
+
Architectures.apply(:cqrs, dsl, **options)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rails_event_driven(dsl, **options)
|
|
53
|
+
Architectures.apply(:event_driven, dsl, **options)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
module Rules
|
|
3
|
+
class NoCyclesRule
|
|
4
|
+
attr_reader :components
|
|
5
|
+
|
|
6
|
+
def initialize(among: nil)
|
|
7
|
+
@components = Array(among).compact.map(&:to_sym)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def id
|
|
11
|
+
"dependencies.no_cycles"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def evaluate(graph)
|
|
15
|
+
cycles(graph).map do |cycle|
|
|
16
|
+
location = first_location_for_cycle(graph, cycle) || SourceLocation.new(graph.root, 1, 1)
|
|
17
|
+
Diagnostic.new(
|
|
18
|
+
rule: id,
|
|
19
|
+
message: "component dependency cycle: #{cycle.join(" -> ")}",
|
|
20
|
+
location: location,
|
|
21
|
+
evidence: cycle.join(" -> ")
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def cycles(graph)
|
|
29
|
+
adjacency = Hash.new { |hash, key| hash[key] = Set.new }
|
|
30
|
+
graph.component_dependency_pairs(only: components).each do |source, target|
|
|
31
|
+
next if source == target
|
|
32
|
+
|
|
33
|
+
adjacency[source].add(target)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
found = Set.new
|
|
37
|
+
result = []
|
|
38
|
+
|
|
39
|
+
adjacency.keys.each do |start|
|
|
40
|
+
walk(adjacency, start, start, [], found, result)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def walk(adjacency, start, node, path, found, result)
|
|
47
|
+
next_path = path + [node]
|
|
48
|
+
|
|
49
|
+
adjacency[node].each do |target|
|
|
50
|
+
if target == start
|
|
51
|
+
cycle = canonical_cycle(next_path + [start])
|
|
52
|
+
key = cycle.join("\0")
|
|
53
|
+
next if found.include?(key)
|
|
54
|
+
|
|
55
|
+
found.add(key)
|
|
56
|
+
result << cycle
|
|
57
|
+
elsif !path.include?(target)
|
|
58
|
+
walk(adjacency, start, target, next_path, found, result)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def canonical_cycle(cycle)
|
|
64
|
+
body = cycle[0...-1]
|
|
65
|
+
rotations = body.each_index.map { |index| body.rotate(index) }
|
|
66
|
+
canonical = rotations.min_by { |rotation| rotation.map(&:to_s) }
|
|
67
|
+
canonical + [canonical.first]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def first_location_for_cycle(graph, cycle)
|
|
71
|
+
source, target = cycle
|
|
72
|
+
graph.dependency_edges.find do |edge|
|
|
73
|
+
graph.component_names_for_path(edge.from_path).include?(source) &&
|
|
74
|
+
graph.target_components_for(edge).include?(target)
|
|
75
|
+
end&.location
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|