archspec 0.3.0 → 0.4.0
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 +23 -8
- data/lib/archspec/analyzer.rb +94 -18
- data/lib/archspec/architectures.rb +91 -14
- data/lib/archspec/cli.rb +41 -15
- data/lib/archspec/component_spec.rb +3 -0
- data/lib/archspec/definition.rb +18 -5
- data/lib/archspec/diagnostic.rb +7 -1
- data/lib/archspec/dsl.rb +219 -6
- data/lib/archspec/evaluator.rb +4 -3
- data/lib/archspec/model.rb +38 -3
- data/lib/archspec/presets.rb +3 -0
- data/lib/archspec/rules/component_rules.rb +2 -0
- data/lib/archspec/rules/concern_rules.rb +67 -0
- data/lib/archspec/rules/cycle_rule.rb +2 -0
- data/lib/archspec/rules/dependency_rules.rb +65 -0
- data/lib/archspec/rules/privacy_rule.rb +81 -0
- data/lib/archspec/rules/protocol_rules.rb +62 -13
- data/lib/archspec/rules/zeitwerk_rule.rb +24 -0
- data/lib/archspec/{baseline.rb → todo.rb} +5 -1
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +38 -1
- metadata +19 -3
data/lib/archspec/dsl.rb
CHANGED
|
@@ -1,26 +1,105 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
3
5
|
module ArchSpec
|
|
6
|
+
# The ArchSpec DSL.
|
|
7
|
+
#
|
|
8
|
+
# An +Archspec.rb+ file is evaluated in this context, so every method here is
|
|
9
|
+
# a top-level call in that file.
|
|
4
10
|
module DSL
|
|
11
|
+
# The top-level DSL. Declare the project, its components, an architecture
|
|
12
|
+
# preset, and global rules.
|
|
13
|
+
#
|
|
14
|
+
# root "."
|
|
15
|
+
# source "app/**/*.rb", "lib/**/*.rb"
|
|
16
|
+
# ignore "app/legacy/**/*.rb"
|
|
17
|
+
# todo "archspec_todo.yml"
|
|
18
|
+
#
|
|
19
|
+
# component :models, in: "app/models/**/*.rb"
|
|
20
|
+
# component :controllers, in: "app/controllers/**/*.rb"
|
|
21
|
+
# models.cannot_use :controllers
|
|
22
|
+
#
|
|
23
|
+
# Declaring a component defines a reader for it, so +models+ and
|
|
24
|
+
# +controllers+ above return an ArchSpec::DSL::ComponentProxy you attach
|
|
25
|
+
# rules to.
|
|
5
26
|
module Context
|
|
27
|
+
# Sets or reads the project root that file patterns resolve against.
|
|
28
|
+
# Defaults to the directory of the +Archspec.rb+ file.
|
|
6
29
|
def root(path = nil)
|
|
7
30
|
return root_path unless path
|
|
8
31
|
|
|
9
32
|
self.root_path = path.to_s
|
|
10
33
|
end
|
|
11
34
|
|
|
35
|
+
# Adds glob patterns for the files ArchSpec parses. Defaults cover
|
|
36
|
+
# +app+, +lib+, packs, and engines. Component patterns are always
|
|
37
|
+
# analyzed, so most projects never need this.
|
|
12
38
|
def source(*patterns)
|
|
13
39
|
add_source_patterns(patterns)
|
|
14
40
|
end
|
|
15
41
|
|
|
42
|
+
# Adds glob patterns for files to skip. Combines with the built-in
|
|
43
|
+
# ignores for +.git+, +tmp+, +vendor+, and +node_modules+.
|
|
16
44
|
def ignore(*patterns)
|
|
17
45
|
add_ignore_patterns(patterns)
|
|
18
46
|
end
|
|
19
47
|
|
|
20
|
-
|
|
21
|
-
|
|
48
|
+
# Points at a todo file of accepted violations. Diagnostics recorded there
|
|
49
|
+
# are subtracted from future runs, so you can adopt ArchSpec in an existing
|
|
50
|
+
# app without fixing everything first, then burn the list down.
|
|
51
|
+
#
|
|
52
|
+
# todo "archspec_todo.yml"
|
|
53
|
+
#
|
|
54
|
+
# Write or refresh it with <tt>archspec check --update-todo</tt>.
|
|
55
|
+
def todo(path = 'archspec_todo.yml')
|
|
56
|
+
self.todo_path = path.to_s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Declares acronym inflections for Zeitwerk name checks, mirroring your
|
|
60
|
+
# +config/initializers/inflections.rb+.
|
|
61
|
+
#
|
|
62
|
+
# inflect "api" => "API", "graphql" => "GraphQL"
|
|
63
|
+
#
|
|
64
|
+
# +app/models/api_client.rb+ then expects +APIClient+.
|
|
65
|
+
def inflect(map)
|
|
66
|
+
add_inflections(map)
|
|
22
67
|
end
|
|
23
68
|
|
|
69
|
+
# Yields each subdirectory matching a glob, so you can declare one
|
|
70
|
+
# component per engine or pack without hardcoding their names. Paths
|
|
71
|
+
# resolve against the +Archspec.rb+ directory, not the working directory,
|
|
72
|
+
# so it does not matter where +archspec+ is run from.
|
|
73
|
+
#
|
|
74
|
+
# each_directory "engines/*" do |name, path|
|
|
75
|
+
# component name, in: "#{path}/**/*.rb"
|
|
76
|
+
# end
|
|
77
|
+
#
|
|
78
|
+
# Yields the directory basename and its root-relative path. Returns the
|
|
79
|
+
# <tt>[name, path]</tt> pairs when called without a block.
|
|
80
|
+
def each_directory(glob)
|
|
81
|
+
base = absolute_root
|
|
82
|
+
pairs = Dir.glob(File.join(base, glob)).select { |path| File.directory?(path) }.sort.map do |absolute|
|
|
83
|
+
[File.basename(absolute), Pathname(absolute).relative_path_from(Pathname(base)).to_s]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return pairs unless block_given?
|
|
87
|
+
|
|
88
|
+
pairs.each { |name, path| yield(name, path) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Declares a component: a named set of files, matched by glob, namespace,
|
|
92
|
+
# or explicit constant.
|
|
93
|
+
#
|
|
94
|
+
# component :services, in: "app/services/**/*.rb"
|
|
95
|
+
# component :billing, namespace: "Billing"
|
|
96
|
+
# component :legacy, constants: %w[OldReport OldExport]
|
|
97
|
+
#
|
|
98
|
+
# Returns an ArchSpec::DSL::ComponentProxy for attaching rules. The
|
|
99
|
+
# component is also available by name later in the file.
|
|
100
|
+
#
|
|
101
|
+
# +layer+ and +role+ are aliases. Use whichever word fits the
|
|
102
|
+
# architecture you are describing.
|
|
24
103
|
def component(name, in: nil, namespace: nil, constants: nil)
|
|
25
104
|
add_component(
|
|
26
105
|
ComponentSpec.new(name, files: binding.local_variable_get(:in), namespace: namespace, constants: constants)
|
|
@@ -31,18 +110,44 @@ module ArchSpec
|
|
|
31
110
|
alias layer component
|
|
32
111
|
alias role component
|
|
33
112
|
|
|
113
|
+
# Applies a bundled architecture preset, defining its components and
|
|
114
|
+
# rules together.
|
|
115
|
+
#
|
|
116
|
+
# architecture :rails
|
|
117
|
+
# architecture :hexagonal
|
|
118
|
+
# architecture :modular_monolith, components: { ... }, allow: { ... }
|
|
119
|
+
#
|
|
120
|
+
# See ArchSpec::Architectures for every preset and its options.
|
|
34
121
|
def architecture(name, **options)
|
|
35
122
|
Architectures.apply(name, self, **options)
|
|
36
123
|
end
|
|
37
124
|
|
|
125
|
+
# Forbids dependency cycles between components. Pass +among:+ to limit the
|
|
126
|
+
# check to a subset; omit it to check every declared component.
|
|
127
|
+
#
|
|
128
|
+
# no_cycles!
|
|
129
|
+
# no_cycles! among: %i[billing catalog shared]
|
|
130
|
+
#
|
|
131
|
+
# Rule id: +dependencies.no_cycles+.
|
|
38
132
|
def no_cycles!(among: nil)
|
|
39
133
|
add_rule(Rules::NoCyclesRule.new(among: among))
|
|
40
134
|
end
|
|
41
135
|
|
|
42
|
-
|
|
43
|
-
|
|
136
|
+
# Checks that files define the constant their path implies under Zeitwerk.
|
|
137
|
+
# Pass globs to restrict the check to the autoloaded tree, since Rails
|
|
138
|
+
# does not autoload +lib+ by default.
|
|
139
|
+
#
|
|
140
|
+
# verify_zeitwerk_names!
|
|
141
|
+
# verify_zeitwerk_names! "app/**/*.rb"
|
|
142
|
+
#
|
|
143
|
+
# Rule id: +zeitwerk.naming+.
|
|
144
|
+
def verify_zeitwerk_names!(*only)
|
|
145
|
+
add_rule(Rules::ZeitwerkNamingRule.new(only: only))
|
|
44
146
|
end
|
|
45
147
|
|
|
148
|
+
# Adds a custom rule object. A rule responds to +id+ and
|
|
149
|
+
# <tt>evaluate(graph)</tt>, returning ArchSpec::Diagnostic objects. Use
|
|
150
|
+
# this to extend ArchSpec with project-specific checks.
|
|
46
151
|
def rule(rule)
|
|
47
152
|
add_rule(rule)
|
|
48
153
|
end
|
|
@@ -58,6 +163,11 @@ module ArchSpec
|
|
|
58
163
|
end
|
|
59
164
|
end
|
|
60
165
|
|
|
166
|
+
# A handle to one component, returned by ArchSpec::DSL::Context#component
|
|
167
|
+
# and by calling a declared component's name. Rule methods return +self+, so
|
|
168
|
+
# they chain.
|
|
169
|
+
#
|
|
170
|
+
# services.cannot_use(:controllers).cannot_call(:render, receiver: :none)
|
|
61
171
|
class ComponentProxy
|
|
62
172
|
attr_reader :definition, :name
|
|
63
173
|
|
|
@@ -66,6 +176,13 @@ module ArchSpec
|
|
|
66
176
|
@name = name.to_sym
|
|
67
177
|
end
|
|
68
178
|
|
|
179
|
+
# Allowlists the components this one may depend on. A reference to any
|
|
180
|
+
# other declared component fails.
|
|
181
|
+
#
|
|
182
|
+
# controllers.can_use :models, :services
|
|
183
|
+
#
|
|
184
|
+
# +only_depend_on+ and +must_only_depend_on+ are aliases.
|
|
185
|
+
# Rule id: +dependencies.allow+.
|
|
69
186
|
def can_use(*targets)
|
|
70
187
|
add_rule(Rules::AllowDependenciesRule.new(name, targets))
|
|
71
188
|
self
|
|
@@ -74,36 +191,126 @@ module ArchSpec
|
|
|
74
191
|
alias only_depend_on can_use
|
|
75
192
|
alias must_only_depend_on can_use
|
|
76
193
|
|
|
194
|
+
# Forbids depending on the named components. Narrower than #can_use: only
|
|
195
|
+
# the listed components fail, other dependencies are left alone.
|
|
196
|
+
#
|
|
197
|
+
# models.cannot_use :controllers, :helpers
|
|
198
|
+
#
|
|
199
|
+
# Rule id: +dependencies.forbid+.
|
|
77
200
|
def cannot_use(*targets)
|
|
78
201
|
add_rule(Rules::ForbidDependenciesRule.new(name, targets))
|
|
79
202
|
self
|
|
80
203
|
end
|
|
81
204
|
|
|
82
|
-
|
|
83
|
-
|
|
205
|
+
# Allowlists the components that may reference this one, the inverse of
|
|
206
|
+
# #can_use. A reference from any other component fails. Use it to protect
|
|
207
|
+
# a shared kernel or a component with a deliberately narrow audience.
|
|
208
|
+
#
|
|
209
|
+
# shared_kernel.can_only_be_used_by :billing, :catalog
|
|
210
|
+
#
|
|
211
|
+
# Rule id: +dependencies.consumers+.
|
|
212
|
+
def can_only_be_used_by(*consumers)
|
|
213
|
+
add_rule(Rules::AllowedConsumersRule.new(name, consumers))
|
|
84
214
|
self
|
|
85
215
|
end
|
|
86
216
|
|
|
217
|
+
# Forbids calling the named methods. By default any receiver matches, so
|
|
218
|
+
# this catches +record.update+ and +cache.update+ alike. Pass
|
|
219
|
+
# <tt>receiver: :none</tt> to match only bare, implicit-+self+ calls, which
|
|
220
|
+
# is how the Rails presets keep the controller API out of models.
|
|
221
|
+
#
|
|
222
|
+
# queries.cannot_call :save, :update, :destroy
|
|
223
|
+
# services.cannot_call :render, :params, receiver: :none
|
|
224
|
+
#
|
|
225
|
+
# A bare call to a method the component defines, inherits, or generates
|
|
226
|
+
# with +attr_*+ or +delegate+ is treated as its own API and not flagged.
|
|
227
|
+
# Rule id: +methods.forbid+.
|
|
228
|
+
def cannot_call(*methods, receiver: :any)
|
|
229
|
+
add_rule(Rules::CannotCallRule.new(name, methods, receiver: receiver))
|
|
230
|
+
self
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Forbids defining the named methods in this component. Use it when the
|
|
234
|
+
# method name itself is a design smell there, such as +call+ on a
|
|
235
|
+
# component that should not hold command objects.
|
|
236
|
+
#
|
|
237
|
+
# models.cannot_define :call
|
|
238
|
+
#
|
|
239
|
+
# Rule id: +methods.define_forbid+.
|
|
87
240
|
def cannot_define(*methods)
|
|
88
241
|
add_rule(Rules::CannotDefineMethodRule.new(name, methods))
|
|
89
242
|
self
|
|
90
243
|
end
|
|
91
244
|
|
|
245
|
+
# Forbids the one-shot <tt>Thing.new(...).call</tt> pattern, where a class
|
|
246
|
+
# is instantiated and immediately invoked. Use it to steer a component
|
|
247
|
+
# toward plain methods over anonymous command objects.
|
|
248
|
+
#
|
|
249
|
+
# Rule id: +objects.instantiate_and_invoke_forbid+.
|
|
92
250
|
def cannot_instantiate_and_invoke
|
|
93
251
|
add_rule(Rules::CannotInstantiateAndInvokeRule.new(name))
|
|
94
252
|
self
|
|
95
253
|
end
|
|
96
254
|
|
|
255
|
+
# Forbids referencing the named constants or anything under them. Use this
|
|
256
|
+
# when the boundary is a framework constant rather than a component.
|
|
257
|
+
#
|
|
258
|
+
# models.cannot_reference_constants "ActionController", "ActionView"
|
|
259
|
+
#
|
|
260
|
+
# Rule id: +constants.forbid+.
|
|
97
261
|
def cannot_reference_constants(*constants)
|
|
98
262
|
add_rule(Rules::CannotReferenceConstantsRule.new(name, constants))
|
|
99
263
|
self
|
|
100
264
|
end
|
|
101
265
|
|
|
266
|
+
# Marks part of the component as its public API. References from outside
|
|
267
|
+
# must resolve to a public constant; everything else becomes private.
|
|
268
|
+
#
|
|
269
|
+
# billing.public_api "packs/billing/app/public/**/*.rb"
|
|
270
|
+
# billing.public_api constants: "Billing::Api"
|
|
271
|
+
# billing.public_api namespace: "Billing::Public"
|
|
272
|
+
#
|
|
273
|
+
# +constants+ matches exact names, +namespace+ matches a name and its
|
|
274
|
+
# children. Code inside the component may still reach its own internals.
|
|
275
|
+
# Rule id: +dependencies.privacy+.
|
|
276
|
+
def public_api(*patterns, constants: nil, namespace: nil)
|
|
277
|
+
add_rule(Rules::PublicApiRule.new(name, files: patterns, constants: constants, namespaces: namespace))
|
|
278
|
+
self
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Forbids a concern from referencing the constants that include it. A
|
|
282
|
+
# concern that names its includer knows too much about who uses it, which
|
|
283
|
+
# couples the two and defeats the point of extracting the concern.
|
|
284
|
+
#
|
|
285
|
+
# component :model_concerns, in: "app/models/concerns/**/*.rb"
|
|
286
|
+
# model_concerns.cannot_reference_includers
|
|
287
|
+
#
|
|
288
|
+
# Rule id: +concerns.independence+.
|
|
289
|
+
def cannot_reference_includers
|
|
290
|
+
add_rule(Rules::ConcernIndependenceRule.new(name))
|
|
291
|
+
self
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Requires the component to hold no files. Use it to keep a directory
|
|
295
|
+
# empty, such as +app/services+ in a vanilla Rails app, with a reason
|
|
296
|
+
# shown in the diagnostic.
|
|
297
|
+
#
|
|
298
|
+
# component(:services, in: "app/services/**/*.rb")
|
|
299
|
+
# .must_be_empty(because: "behavior belongs on models")
|
|
300
|
+
#
|
|
301
|
+
# Rule id: +components.empty+.
|
|
102
302
|
def must_be_empty(because: nil)
|
|
103
303
|
add_rule(Rules::MustBeEmptyRule.new(name, because: because))
|
|
104
304
|
self
|
|
105
305
|
end
|
|
106
306
|
|
|
307
|
+
# Requires every class in the component to implement all the named
|
|
308
|
+
# instance methods. Methods inherited from resolvable superclasses or
|
|
309
|
+
# mixins count.
|
|
310
|
+
#
|
|
311
|
+
# commands.must_implement :perform
|
|
312
|
+
#
|
|
313
|
+
# Rule id: +protocol.must_implement+.
|
|
107
314
|
def must_implement(*methods)
|
|
108
315
|
methods.each do |method_name|
|
|
109
316
|
add_rule(Rules::MustImplementRule.new(name, method_name))
|
|
@@ -111,6 +318,12 @@ module ArchSpec
|
|
|
111
318
|
self
|
|
112
319
|
end
|
|
113
320
|
|
|
321
|
+
# Requires every class in the component to implement at least one of the
|
|
322
|
+
# named instance methods. Useful when a protocol allows either name.
|
|
323
|
+
#
|
|
324
|
+
# commands.must_implement_one_of :perform, :call
|
|
325
|
+
#
|
|
326
|
+
# Rule id: +protocol.must_implement_one_of+.
|
|
114
327
|
def must_implement_one_of(*methods)
|
|
115
328
|
add_rule(Rules::MustImplementOneOfRule.new(name, methods))
|
|
116
329
|
self
|
data/lib/archspec/evaluator.rb
CHANGED
|
@@ -4,14 +4,15 @@ module ArchSpec
|
|
|
4
4
|
module Evaluator
|
|
5
5
|
extend self
|
|
6
6
|
|
|
7
|
-
def evaluate(definition, graph,
|
|
7
|
+
def evaluate(definition, graph, todo: Todo.empty)
|
|
8
8
|
(parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) })
|
|
9
9
|
.reject { |diagnostic| graph.suppressed?(diagnostic) }
|
|
10
|
-
.reject { |diagnostic|
|
|
10
|
+
.reject { |diagnostic| todo.include?(diagnostic) }
|
|
11
11
|
.sort_by do |diagnostic|
|
|
12
12
|
[diagnostic.location.path, diagnostic.location.line, diagnostic.rule,
|
|
13
|
-
diagnostic.message]
|
|
13
|
+
diagnostic.message, diagnostic.evidence]
|
|
14
14
|
end
|
|
15
|
+
.uniq { |diagnostic| [diagnostic.rule, diagnostic.message, diagnostic.location.path, diagnostic.location.line] }
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
private
|
data/lib/archspec/model.rb
CHANGED
|
@@ -71,7 +71,7 @@ module ArchSpec
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence)
|
|
74
|
+
Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence, :receiver)
|
|
75
75
|
|
|
76
76
|
class Component
|
|
77
77
|
attr_reader :name, :files, :constants, :file_reasons, :constant_reasons
|
|
@@ -104,6 +104,8 @@ module ArchSpec
|
|
|
104
104
|
extends
|
|
105
105
|
].freeze
|
|
106
106
|
|
|
107
|
+
RESOLVED_ROOTS = %w[Object BasicObject].freeze
|
|
108
|
+
|
|
107
109
|
attr_reader :root, :files, :constants, :edges, :components
|
|
108
110
|
|
|
109
111
|
def initialize(root)
|
|
@@ -136,8 +138,8 @@ module ArchSpec
|
|
|
136
138
|
constant
|
|
137
139
|
end
|
|
138
140
|
|
|
139
|
-
def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high)
|
|
140
|
-
edges << Edge.new(type, from_path, from_constant, normalize_constant(to), location, confidence)
|
|
141
|
+
def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil)
|
|
142
|
+
edges << Edge.new(type, from_path, from_constant, normalize_constant(to), location, confidence, receiver)
|
|
141
143
|
end
|
|
142
144
|
|
|
143
145
|
def constants_named(name)
|
|
@@ -225,6 +227,39 @@ module ArchSpec
|
|
|
225
227
|
candidates.find { |candidate| constants_named(candidate).any? } || normalized
|
|
226
228
|
end
|
|
227
229
|
|
|
230
|
+
# Instance methods a constant responds to, walking resolvable superclasses
|
|
231
|
+
# and include/prepend mixins. Returns [methods, unresolved ancestor names];
|
|
232
|
+
# a non-empty second element means the answer is incomplete.
|
|
233
|
+
def effective_instance_methods(name, visited = Set.new)
|
|
234
|
+
normalized = normalize_constant(name)
|
|
235
|
+
return [Set.new, Set.new] if visited.include?(normalized)
|
|
236
|
+
|
|
237
|
+
visited.add(normalized)
|
|
238
|
+
return [Set.new, Set.new] if RESOLVED_ROOTS.include?(normalized)
|
|
239
|
+
|
|
240
|
+
nodes = constants_named(normalized)
|
|
241
|
+
return [Set.new, Set[normalized]] if nodes.empty?
|
|
242
|
+
|
|
243
|
+
methods = Set.new
|
|
244
|
+
unresolved = Set.new
|
|
245
|
+
|
|
246
|
+
nodes.each do |node|
|
|
247
|
+
methods.merge(node.instance_methods)
|
|
248
|
+
|
|
249
|
+
ancestors = node.mixins[:include].to_a + node.mixins[:prepend].to_a
|
|
250
|
+
ancestors << node.superclass if node.superclass
|
|
251
|
+
|
|
252
|
+
ancestors.each do |ancestor|
|
|
253
|
+
resolved_name = resolve_constant_reference(ancestor, node.name)
|
|
254
|
+
ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
|
|
255
|
+
methods.merge(ancestor_methods)
|
|
256
|
+
unresolved.merge(ancestor_unresolved)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
[methods, unresolved]
|
|
261
|
+
end
|
|
262
|
+
|
|
228
263
|
def component_dependency_pairs(only: nil)
|
|
229
264
|
allowed_sources = Array(only).compact.map(&:to_sym).to_set
|
|
230
265
|
pairs = Set.new
|
data/lib/archspec/presets.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
|
+
# Backwards-compatible alias for ArchSpec::Architectures. New code should use
|
|
5
|
+
# +architecture+ in the DSL and the ArchSpec::Architectures module.
|
|
4
6
|
module Presets
|
|
5
7
|
module_function
|
|
6
8
|
|
|
9
|
+
# Delegates to ArchSpec::Architectures.apply.
|
|
7
10
|
def apply(name, dsl, **options)
|
|
8
11
|
Architectures.apply(name, dsl, **options)
|
|
9
12
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Rules
|
|
5
|
+
# Backs ArchSpec::DSL::ComponentProxy#cannot_reference_includers. A concern
|
|
6
|
+
# is a module mixed into other classes; this flags a concern that names the
|
|
7
|
+
# very constant that includes it, which is a circular knowledge dependency.
|
|
8
|
+
class ConcernIndependenceRule
|
|
9
|
+
MIXIN_TYPES = %i[includes prepends extends].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :source
|
|
12
|
+
|
|
13
|
+
def initialize(source)
|
|
14
|
+
@source = source.to_sym
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def merge_key
|
|
18
|
+
[self.class, source]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def id
|
|
22
|
+
'concerns.independence'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def evaluate(graph)
|
|
26
|
+
component = graph.components[source]
|
|
27
|
+
return [] unless component
|
|
28
|
+
|
|
29
|
+
includers = includers_by_module(graph)
|
|
30
|
+
|
|
31
|
+
graph.dependency_edges.filter_map do |edge|
|
|
32
|
+
next unless edge.from_constant && component.constants.include?(edge.from_constant)
|
|
33
|
+
|
|
34
|
+
consumers = includers[edge.from_constant]
|
|
35
|
+
next if consumers.empty?
|
|
36
|
+
|
|
37
|
+
target = graph.resolve_constant_reference(edge.to, edge.from_constant)
|
|
38
|
+
includer = consumers.find { |name| target == name || target.start_with?("#{name}::") }
|
|
39
|
+
next unless includer
|
|
40
|
+
|
|
41
|
+
Diagnostic.new(
|
|
42
|
+
rule: id,
|
|
43
|
+
message: "#{edge.from_constant} must not reference its includer #{includer}",
|
|
44
|
+
location: edge.location,
|
|
45
|
+
evidence: "#{edge.from_constant} #{edge.type} #{target}"
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Maps each mixed-in module to the set of constants that mix it in.
|
|
53
|
+
def includers_by_module(graph)
|
|
54
|
+
map = Hash.new { |hash, key| hash[key] = Set.new }
|
|
55
|
+
|
|
56
|
+
graph.edges.each do |edge|
|
|
57
|
+
next unless MIXIN_TYPES.include?(edge.type) && edge.from_constant
|
|
58
|
+
|
|
59
|
+
mod = graph.resolve_constant_reference(edge.to, edge.from_constant)
|
|
60
|
+
map[mod].add(edge.from_constant) unless mod == edge.from_constant
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
map
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
|
+
# The checks ArchSpec runs. Each rule responds to +id+ (its stable rule id,
|
|
5
|
+
# used in suppressions and the todo file) and <tt>evaluate(graph)</tt>, which
|
|
6
|
+
# returns ArchSpec::Diagnostic objects. You normally add rules through the
|
|
7
|
+
# ArchSpec::DSL rather than instantiating these directly, but you can pass a
|
|
8
|
+
# custom object of the same shape to ArchSpec::DSL::Context#rule.
|
|
4
9
|
module Rules
|
|
10
|
+
# Base for the allow and forbid dependency rules. Merges repeated
|
|
11
|
+
# declarations for the same source component.
|
|
5
12
|
class DependencyRule
|
|
6
13
|
attr_reader :source, :targets
|
|
7
14
|
|
|
@@ -34,6 +41,8 @@ module ArchSpec
|
|
|
34
41
|
end
|
|
35
42
|
end
|
|
36
43
|
|
|
44
|
+
# Backs ArchSpec::DSL::ComponentProxy#can_use. Flags references from the
|
|
45
|
+
# source to any component outside its allowlist.
|
|
37
46
|
class AllowDependenciesRule < DependencyRule
|
|
38
47
|
def id
|
|
39
48
|
'dependencies.allow'
|
|
@@ -55,6 +64,8 @@ module ArchSpec
|
|
|
55
64
|
end
|
|
56
65
|
end
|
|
57
66
|
|
|
67
|
+
# Backs ArchSpec::DSL::ComponentProxy#cannot_use. Flags references from the
|
|
68
|
+
# source to any of the named components.
|
|
58
69
|
class ForbidDependenciesRule < DependencyRule
|
|
59
70
|
def id
|
|
60
71
|
'dependencies.forbid'
|
|
@@ -76,6 +87,60 @@ module ArchSpec
|
|
|
76
87
|
end
|
|
77
88
|
end
|
|
78
89
|
|
|
90
|
+
# Backs ArchSpec::DSL::ComponentProxy#can_only_be_used_by. The inverse of an
|
|
91
|
+
# allowlist: flags references to the component from any component that is not
|
|
92
|
+
# an approved consumer. Use it to protect a shared kernel.
|
|
93
|
+
class AllowedConsumersRule
|
|
94
|
+
attr_reader :source, :consumers
|
|
95
|
+
|
|
96
|
+
def initialize(source, consumers)
|
|
97
|
+
@source = source.to_sym
|
|
98
|
+
@consumers = Array(consumers).flatten.map(&:to_sym).to_set
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def merge_key
|
|
102
|
+
[self.class, source]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def merge!(other)
|
|
106
|
+
consumers.merge(other.consumers)
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def id
|
|
111
|
+
'dependencies.consumers'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def evaluate(graph)
|
|
115
|
+
graph.dependency_edges.flat_map do |edge|
|
|
116
|
+
next [] unless graph.target_components_for(edge).include?(source)
|
|
117
|
+
|
|
118
|
+
offenders = graph.component_names_for_path(edge.from_path).reject do |component|
|
|
119
|
+
component == source || consumers.include?(component)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
offenders.map do |offender|
|
|
123
|
+
Diagnostic.new(
|
|
124
|
+
rule: id,
|
|
125
|
+
message: message_for(offender),
|
|
126
|
+
location: edge.location,
|
|
127
|
+
evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge.to}"
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def message_for(offender)
|
|
136
|
+
return "#{source} may not be used by #{offender}" if consumers.empty?
|
|
137
|
+
|
|
138
|
+
"#{source} may only be used by #{consumers.to_a.sort.join(', ')}, not #{offender}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Backs ArchSpec::DSL::ComponentProxy#cannot_reference_constants. Flags
|
|
143
|
+
# references to the named constants or anything nested under them.
|
|
79
144
|
class CannotReferenceConstantsRule
|
|
80
145
|
attr_reader :source, :constants
|
|
81
146
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Rules
|
|
5
|
+
# Backs ArchSpec::DSL::ComponentProxy#public_api. Flags references from
|
|
6
|
+
# outside the component to constants that are not part of its public API.
|
|
7
|
+
class PublicApiRule
|
|
8
|
+
attr_reader :source, :file_patterns, :constants, :namespaces
|
|
9
|
+
|
|
10
|
+
def initialize(source, files: [], constants: nil, namespaces: nil)
|
|
11
|
+
@source = source.to_sym
|
|
12
|
+
@file_patterns = Array(files).flatten.compact.map(&:to_s)
|
|
13
|
+
@constants = Array(constants).compact.map { |value| normalize_constant(value) }
|
|
14
|
+
@namespaces = Array(namespaces).compact.map { |value| normalize_constant(value) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def merge_key
|
|
18
|
+
[self.class, source]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def merge!(other)
|
|
22
|
+
@file_patterns |= other.file_patterns
|
|
23
|
+
@constants |= other.constants
|
|
24
|
+
@namespaces |= other.namespaces
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def id
|
|
29
|
+
'dependencies.privacy'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def evaluate(graph)
|
|
33
|
+
component = graph.components[source]
|
|
34
|
+
return [] unless component
|
|
35
|
+
|
|
36
|
+
public_names = public_constant_names(graph)
|
|
37
|
+
|
|
38
|
+
graph.dependency_edges.filter_map do |edge|
|
|
39
|
+
next if component.files.include?(edge.from_path)
|
|
40
|
+
|
|
41
|
+
resolved = graph.resolve_constant_reference(edge.to, edge.from_constant)
|
|
42
|
+
next unless graph.component_names_for_constant(resolved).include?(source)
|
|
43
|
+
next if public?(resolved, public_names)
|
|
44
|
+
|
|
45
|
+
Diagnostic.new(
|
|
46
|
+
rule: id,
|
|
47
|
+
message: "#{resolved} is private to #{source}",
|
|
48
|
+
location: edge.location,
|
|
49
|
+
evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{resolved}"
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def public_constant_names(graph)
|
|
57
|
+
paths = file_patterns.flat_map do |pattern|
|
|
58
|
+
Dir.glob(File.absolute_path(pattern, graph.root))
|
|
59
|
+
end.map { |path| File.expand_path(path) }.to_set
|
|
60
|
+
|
|
61
|
+
names = graph.constants.select { |constant| paths.include?(constant.path) }.map(&:name)
|
|
62
|
+
(names + constants).to_set
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Names from public files and constants: match exactly. Reopened
|
|
66
|
+
# namespace modules land in public files too, so prefix matching there
|
|
67
|
+
# would make the whole namespace public. Use namespace: for prefixes.
|
|
68
|
+
def public?(name, public_names)
|
|
69
|
+
return true if public_names.include?(name)
|
|
70
|
+
|
|
71
|
+
namespaces.any? do |namespace|
|
|
72
|
+
name == namespace || name.start_with?("#{namespace}::")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_constant(value)
|
|
77
|
+
value.to_s.sub(/\A::/, '')
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|