archspec 0.3.0 → 0.5.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 +30 -11
- data/lib/archspec/analyzer.rb +224 -59
- data/lib/archspec/architectures.rb +134 -28
- data/lib/archspec/cli.rb +42 -88
- data/lib/archspec/component_spec.rb +3 -0
- data/lib/archspec/definition.rb +12 -4
- data/lib/archspec/diagnostic.rb +7 -1
- data/lib/archspec/dsl.rb +220 -16
- data/lib/archspec/evaluator.rb +4 -3
- data/lib/archspec/formatters/explanation.rb +93 -0
- data/lib/archspec/model.rb +61 -13
- 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/naming_rules.rb +209 -0
- data/lib/archspec/rules/privacy_rule.rb +81 -0
- data/lib/archspec/rules/protocol_rules.rb +62 -13
- data/lib/archspec/{baseline.rb → todo.rb} +5 -1
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +40 -3
- metadata +21 -5
- data/lib/archspec/presets.rb +0 -11
- data/lib/archspec/rules/zeitwerk_rule.rb +0 -27
data/lib/archspec/dsl.rb
CHANGED
|
@@ -1,26 +1,92 @@
|
|
|
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
|
+
# Yields each subdirectory matching a glob, so you can declare one
|
|
60
|
+
# component per engine or pack without hardcoding their names. Paths
|
|
61
|
+
# resolve against the +Archspec.rb+ directory, not the working directory,
|
|
62
|
+
# so it does not matter where +archspec+ is run from.
|
|
63
|
+
#
|
|
64
|
+
# each_directory "engines/*" do |name, path|
|
|
65
|
+
# component name, in: "#{path}/**/*.rb"
|
|
66
|
+
# end
|
|
67
|
+
#
|
|
68
|
+
# Yields the directory basename and its root-relative path. Returns the
|
|
69
|
+
# <tt>[name, path]</tt> pairs when called without a block.
|
|
70
|
+
def each_directory(glob)
|
|
71
|
+
base = absolute_root
|
|
72
|
+
pairs = Dir.glob(File.join(base, glob)).select { |path| File.directory?(path) }.sort.map do |absolute|
|
|
73
|
+
[File.basename(absolute), Pathname(absolute).relative_path_from(Pathname(base)).to_s]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return pairs unless block_given?
|
|
77
|
+
|
|
78
|
+
pairs.each { |name, path| yield(name, path) }
|
|
22
79
|
end
|
|
23
80
|
|
|
81
|
+
# Declares a component: a named set of files, matched by glob, namespace,
|
|
82
|
+
# or explicit constant.
|
|
83
|
+
#
|
|
84
|
+
# component :services, in: "app/services/**/*.rb"
|
|
85
|
+
# component :billing, namespace: "Billing"
|
|
86
|
+
# component :legacy, constants: %w[OldReport OldExport]
|
|
87
|
+
#
|
|
88
|
+
# Returns an ArchSpec::DSL::ComponentProxy for attaching rules. The
|
|
89
|
+
# component is also available by name later in the file.
|
|
24
90
|
def component(name, in: nil, namespace: nil, constants: nil)
|
|
25
91
|
add_component(
|
|
26
92
|
ComponentSpec.new(name, files: binding.local_variable_get(:in), namespace: namespace, constants: constants)
|
|
@@ -28,21 +94,38 @@ module ArchSpec
|
|
|
28
94
|
ComponentProxy.new(self, name)
|
|
29
95
|
end
|
|
30
96
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
97
|
+
# Applies a bundled architecture preset, defining its components and
|
|
98
|
+
# rules together.
|
|
99
|
+
#
|
|
100
|
+
# architecture :rails
|
|
101
|
+
# architecture :hexagonal
|
|
102
|
+
# architecture :modular_monolith, components: { ... }, allow: { ... }
|
|
103
|
+
#
|
|
104
|
+
# +preset+ is an alias. Use whichever word fits: +architecture+ reads well
|
|
105
|
+
# for structural bundles like +:rails+, +preset+ for convention packs like
|
|
106
|
+
# +:ruby_conventions+.
|
|
107
|
+
#
|
|
108
|
+
# See ArchSpec::Architectures for every preset and its options.
|
|
34
109
|
def architecture(name, **options)
|
|
35
110
|
Architectures.apply(name, self, **options)
|
|
36
111
|
end
|
|
37
112
|
|
|
38
|
-
|
|
39
|
-
add_rule(Rules::NoCyclesRule.new(among: among))
|
|
40
|
-
end
|
|
113
|
+
alias preset architecture
|
|
41
114
|
|
|
42
|
-
|
|
43
|
-
|
|
115
|
+
# Forbids dependency cycles between components. Pass +among:+ to limit the
|
|
116
|
+
# check to a subset; omit it to check every declared component.
|
|
117
|
+
#
|
|
118
|
+
# no_cycles
|
|
119
|
+
# no_cycles among: %i[billing catalog shared]
|
|
120
|
+
#
|
|
121
|
+
# Rule id: +dependencies.no_cycles+.
|
|
122
|
+
def no_cycles(among: nil)
|
|
123
|
+
add_rule(Rules::NoCyclesRule.new(among: among))
|
|
44
124
|
end
|
|
45
125
|
|
|
126
|
+
# Adds a custom rule object. A rule responds to +id+ and
|
|
127
|
+
# <tt>evaluate(graph)</tt>, returning ArchSpec::Diagnostic objects. Use
|
|
128
|
+
# this to extend ArchSpec with project-specific checks.
|
|
46
129
|
def rule(rule)
|
|
47
130
|
add_rule(rule)
|
|
48
131
|
end
|
|
@@ -58,6 +141,11 @@ module ArchSpec
|
|
|
58
141
|
end
|
|
59
142
|
end
|
|
60
143
|
|
|
144
|
+
# A handle to one component, returned by ArchSpec::DSL::Context#component
|
|
145
|
+
# and by calling a declared component's name. Rule methods return +self+, so
|
|
146
|
+
# they chain.
|
|
147
|
+
#
|
|
148
|
+
# services.cannot_use(:controllers).cannot_call(:render, receiver: :none)
|
|
61
149
|
class ComponentProxy
|
|
62
150
|
attr_reader :definition, :name
|
|
63
151
|
|
|
@@ -66,44 +154,139 @@ module ArchSpec
|
|
|
66
154
|
@name = name.to_sym
|
|
67
155
|
end
|
|
68
156
|
|
|
69
|
-
|
|
157
|
+
# Allowlists the components this one may depend on: only the listed
|
|
158
|
+
# components are permitted, and a reference to any other declared
|
|
159
|
+
# component fails. The mirror image of #can_only_be_used_by.
|
|
160
|
+
#
|
|
161
|
+
# controllers.can_only_use :models, :services
|
|
162
|
+
#
|
|
163
|
+
# Rule id: +dependencies.allow+.
|
|
164
|
+
def can_only_use(*targets)
|
|
70
165
|
add_rule(Rules::AllowDependenciesRule.new(name, targets))
|
|
71
166
|
self
|
|
72
167
|
end
|
|
73
168
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
169
|
+
# Forbids depending on the named components. Narrower than #can_only_use:
|
|
170
|
+
# only the listed components fail, other dependencies are left alone.
|
|
171
|
+
#
|
|
172
|
+
# models.cannot_use :controllers, :helpers
|
|
173
|
+
#
|
|
174
|
+
# Rule id: +dependencies.forbid+.
|
|
77
175
|
def cannot_use(*targets)
|
|
78
176
|
add_rule(Rules::ForbidDependenciesRule.new(name, targets))
|
|
79
177
|
self
|
|
80
178
|
end
|
|
81
179
|
|
|
82
|
-
|
|
83
|
-
|
|
180
|
+
# Allowlists the components that may reference this one, the inverse of
|
|
181
|
+
# #can_only_use. A reference from any other component fails. Use it to protect
|
|
182
|
+
# a shared kernel or a component with a deliberately narrow audience.
|
|
183
|
+
#
|
|
184
|
+
# shared_kernel.can_only_be_used_by :billing, :catalog
|
|
185
|
+
#
|
|
186
|
+
# Rule id: +dependencies.consumers+.
|
|
187
|
+
def can_only_be_used_by(*consumers)
|
|
188
|
+
add_rule(Rules::AllowedConsumersRule.new(name, consumers))
|
|
84
189
|
self
|
|
85
190
|
end
|
|
86
191
|
|
|
192
|
+
# Forbids calling the named methods. By default any receiver matches, so
|
|
193
|
+
# this catches +record.update+ and +cache.update+ alike. Pass
|
|
194
|
+
# <tt>receiver: :none</tt> to match only bare, implicit-+self+ calls, which
|
|
195
|
+
# is how the Rails presets keep the controller API out of models.
|
|
196
|
+
#
|
|
197
|
+
# queries.cannot_call :save, :update, :destroy
|
|
198
|
+
# services.cannot_call :render, :params, receiver: :none
|
|
199
|
+
#
|
|
200
|
+
# A bare call to a method the component defines, inherits, or generates
|
|
201
|
+
# with +attr_*+, Rails +attribute+, or +delegate+ is treated as its own API
|
|
202
|
+
# and not flagged.
|
|
203
|
+
# Rule id: +methods.forbid+.
|
|
204
|
+
def cannot_call(*methods, receiver: :any)
|
|
205
|
+
add_rule(Rules::CannotCallRule.new(name, methods, receiver: receiver))
|
|
206
|
+
self
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Forbids defining the named methods in this component. Use it when the
|
|
210
|
+
# method name itself is a design smell there, such as +call+ on a
|
|
211
|
+
# component that should not hold command objects.
|
|
212
|
+
#
|
|
213
|
+
# models.cannot_define :call
|
|
214
|
+
#
|
|
215
|
+
# Rule id: +methods.define_forbid+.
|
|
87
216
|
def cannot_define(*methods)
|
|
88
217
|
add_rule(Rules::CannotDefineMethodRule.new(name, methods))
|
|
89
218
|
self
|
|
90
219
|
end
|
|
91
220
|
|
|
221
|
+
# Forbids the one-shot <tt>Thing.new(...).call</tt> pattern, where a class
|
|
222
|
+
# is instantiated and immediately invoked. Use it to steer a component
|
|
223
|
+
# toward plain methods over anonymous command objects.
|
|
224
|
+
#
|
|
225
|
+
# Rule id: +objects.instantiate_and_invoke_forbid+.
|
|
92
226
|
def cannot_instantiate_and_invoke
|
|
93
227
|
add_rule(Rules::CannotInstantiateAndInvokeRule.new(name))
|
|
94
228
|
self
|
|
95
229
|
end
|
|
96
230
|
|
|
231
|
+
# Forbids referencing the named constants or anything under them. Use this
|
|
232
|
+
# when the boundary is a framework constant rather than a component.
|
|
233
|
+
#
|
|
234
|
+
# models.cannot_reference_constants "ActionController", "ActionView"
|
|
235
|
+
#
|
|
236
|
+
# Rule id: +constants.forbid+.
|
|
97
237
|
def cannot_reference_constants(*constants)
|
|
98
238
|
add_rule(Rules::CannotReferenceConstantsRule.new(name, constants))
|
|
99
239
|
self
|
|
100
240
|
end
|
|
101
241
|
|
|
242
|
+
# Marks part of the component as its public API. References from outside
|
|
243
|
+
# must resolve to a public constant; everything else becomes private.
|
|
244
|
+
#
|
|
245
|
+
# billing.public_api "packs/billing/app/public/**/*.rb"
|
|
246
|
+
# billing.public_api constants: "Billing::Api"
|
|
247
|
+
# billing.public_api namespace: "Billing::Public"
|
|
248
|
+
#
|
|
249
|
+
# +constants+ matches exact names, +namespace+ matches a name and its
|
|
250
|
+
# children. Code inside the component may still reach its own internals.
|
|
251
|
+
# Rule id: +dependencies.privacy+.
|
|
252
|
+
def public_api(*patterns, constants: nil, namespace: nil)
|
|
253
|
+
add_rule(Rules::PublicApiRule.new(name, files: patterns, constants: constants, namespaces: namespace))
|
|
254
|
+
self
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Forbids a concern from referencing the constants that include it. A
|
|
258
|
+
# concern that names its includer knows too much about who uses it, which
|
|
259
|
+
# couples the two and defeats the point of extracting the concern.
|
|
260
|
+
#
|
|
261
|
+
# component :model_concerns, in: "app/models/concerns/**/*.rb"
|
|
262
|
+
# model_concerns.cannot_reference_includers
|
|
263
|
+
#
|
|
264
|
+
# Rule id: +concerns.independence+.
|
|
265
|
+
def cannot_reference_includers
|
|
266
|
+
add_rule(Rules::ConcernIndependenceRule.new(name))
|
|
267
|
+
self
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Requires the component to hold no files. Use it to keep a directory
|
|
271
|
+
# empty, such as +app/services+ in a vanilla Rails app, with a reason
|
|
272
|
+
# shown in the diagnostic.
|
|
273
|
+
#
|
|
274
|
+
# component(:services, in: "app/services/**/*.rb")
|
|
275
|
+
# .must_be_empty(because: "behavior belongs on models")
|
|
276
|
+
#
|
|
277
|
+
# Rule id: +components.empty+.
|
|
102
278
|
def must_be_empty(because: nil)
|
|
103
279
|
add_rule(Rules::MustBeEmptyRule.new(name, because: because))
|
|
104
280
|
self
|
|
105
281
|
end
|
|
106
282
|
|
|
283
|
+
# Requires every class in the component to implement all the named
|
|
284
|
+
# instance methods. Methods inherited from resolvable superclasses or
|
|
285
|
+
# mixins count.
|
|
286
|
+
#
|
|
287
|
+
# commands.must_implement :perform
|
|
288
|
+
#
|
|
289
|
+
# Rule id: +protocol.must_implement+.
|
|
107
290
|
def must_implement(*methods)
|
|
108
291
|
methods.each do |method_name|
|
|
109
292
|
add_rule(Rules::MustImplementRule.new(name, method_name))
|
|
@@ -111,11 +294,32 @@ module ArchSpec
|
|
|
111
294
|
self
|
|
112
295
|
end
|
|
113
296
|
|
|
297
|
+
# Requires every class in the component to implement at least one of the
|
|
298
|
+
# named instance methods. Useful when a protocol allows either name.
|
|
299
|
+
#
|
|
300
|
+
# commands.must_implement_one_of :perform, :call
|
|
301
|
+
#
|
|
302
|
+
# Rule id: +protocol.must_implement_one_of+.
|
|
114
303
|
def must_implement_one_of(*methods)
|
|
115
304
|
add_rule(Rules::MustImplementOneOfRule.new(name, methods))
|
|
116
305
|
self
|
|
117
306
|
end
|
|
118
307
|
|
|
308
|
+
# Starts a naming-convention rule over the component's defined, public
|
|
309
|
+
# methods. Select the methods with +matching+, then assert something about
|
|
310
|
+
# them. Every check is name-based and exact.
|
|
311
|
+
#
|
|
312
|
+
# models.method_names.matching(/\A(get|set)_/).forbidden
|
|
313
|
+
# chat.method_names.matching(/\Awith_(?<base>.+)/).requires("without_%{base}")
|
|
314
|
+
# chat.method_names.matching(/\Awith_(?<b>.+)/).requires("%{b}", on: agent, scope: :class)
|
|
315
|
+
#
|
|
316
|
+
# Pass <tt>scope: :class</tt> to select class methods instead of instance
|
|
317
|
+
# methods. See ArchSpec::Rules::Naming::Selected for the constraints
|
|
318
|
+
# (+forbidden+, +requires+). Rule ids: +naming.forbidden+, +naming.requires+.
|
|
319
|
+
def method_names(scope: :instance)
|
|
320
|
+
Rules::Naming::Builder.new(self, scope: scope)
|
|
321
|
+
end
|
|
322
|
+
|
|
119
323
|
private
|
|
120
324
|
|
|
121
325
|
def add_rule(rule)
|
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
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Formatters
|
|
5
|
+
# Renders <tt>archspec explain</tt>: why a file or constant belongs to its
|
|
6
|
+
# components, and the facts ArchSpec found for it. Raises ArchSpec::Error
|
|
7
|
+
# when the subject matches no file and no constant.
|
|
8
|
+
module Explanation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def print(output = $stdout, graph:, subject:)
|
|
12
|
+
path = File.expand_path(subject, graph.root)
|
|
13
|
+
|
|
14
|
+
if graph.files.key?(path)
|
|
15
|
+
explain_file(output, graph, path)
|
|
16
|
+
else
|
|
17
|
+
explain_constant(output, graph, subject)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def explain_file(output, graph, path)
|
|
22
|
+
file = graph.files.fetch(path)
|
|
23
|
+
output.puts file.relative_path
|
|
24
|
+
output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(', ')}"
|
|
25
|
+
print_parse_errors(output, file)
|
|
26
|
+
print_component_reasons(output, graph.component_assignment_reasons_for_path(path))
|
|
27
|
+
print_suppressions(output, file)
|
|
28
|
+
output.puts ' outgoing facts:'
|
|
29
|
+
|
|
30
|
+
graph.edges.select { |edge| edge.from_path == path }.each do |edge|
|
|
31
|
+
output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def explain_constant(output, graph, subject)
|
|
36
|
+
constants = graph.constants_named(subject)
|
|
37
|
+
raise Error, "No file or constant found for #{subject.inspect}" if constants.empty?
|
|
38
|
+
|
|
39
|
+
constants.each do |constant|
|
|
40
|
+
output.puts constant.name
|
|
41
|
+
output.puts " kind: #{constant.kind}"
|
|
42
|
+
output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
|
|
43
|
+
print_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
|
|
44
|
+
output.puts " superclass: #{constant.superclass || '(none)'}"
|
|
45
|
+
output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(', ')}"
|
|
46
|
+
output.puts " class methods: #{constant.class_methods.to_a.sort.join(', ')}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def print_component_reasons(output, assignments)
|
|
51
|
+
if assignments.empty?
|
|
52
|
+
output.puts ' components: (none)'
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
output.puts ' components:'
|
|
57
|
+
assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
|
|
58
|
+
output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def print_suppressions(output, file)
|
|
63
|
+
return if file.suppressions.empty?
|
|
64
|
+
|
|
65
|
+
output.puts ' suppressions:'
|
|
66
|
+
file.suppressions.each do |suppression|
|
|
67
|
+
rule = suppression.rule || '*'
|
|
68
|
+
reason = suppression.reason ? " -- #{suppression.reason}" : ''
|
|
69
|
+
output.puts " #{rule} on line #{line_range(suppression)}#{reason}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def print_parse_errors(output, file)
|
|
74
|
+
return if file.parse_errors.empty?
|
|
75
|
+
|
|
76
|
+
output.puts ' parse errors:'
|
|
77
|
+
file.parse_errors.each do |parse_error|
|
|
78
|
+
output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def line_range(suppression)
|
|
83
|
+
if suppression.end_line == Float::INFINITY
|
|
84
|
+
"#{suppression.start_line}-EOF"
|
|
85
|
+
elsif suppression.start_line == suppression.end_line
|
|
86
|
+
suppression.start_line
|
|
87
|
+
else
|
|
88
|
+
"#{suppression.start_line}-#{suppression.end_line}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/archspec/model.rb
CHANGED
|
@@ -7,7 +7,7 @@ require_relative 'value_object'
|
|
|
7
7
|
|
|
8
8
|
module ArchSpec
|
|
9
9
|
ParseError = ValueObject.define(:message, :location)
|
|
10
|
-
MethodDefinition = ValueObject.define(:owner, :name, :scope, :location)
|
|
10
|
+
MethodDefinition = ValueObject.define(:owner, :name, :scope, :location, :visibility)
|
|
11
11
|
|
|
12
12
|
Suppression = ValueObject.define(:rule, :start_line, :end_line, :reason) do
|
|
13
13
|
def matches?(diagnostic)
|
|
@@ -18,12 +18,11 @@ module ArchSpec
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
class SourceFile
|
|
21
|
-
attr_reader :path, :relative_path, :
|
|
21
|
+
attr_reader :path, :relative_path, :parse_errors, :suppressions
|
|
22
22
|
|
|
23
|
-
def initialize(root:, path:,
|
|
23
|
+
def initialize(root:, path:, parse_errors:, suppressions:)
|
|
24
24
|
@path = path
|
|
25
25
|
@relative_path = Pathname(path).relative_path_from(Pathname(root)).to_s
|
|
26
|
-
@expected_constant = expected_constant
|
|
27
26
|
@parse_errors = parse_errors
|
|
28
27
|
@suppressions = suppressions
|
|
29
28
|
end
|
|
@@ -56,22 +55,31 @@ module ArchSpec
|
|
|
56
55
|
kind == :module
|
|
57
56
|
end
|
|
58
57
|
|
|
59
|
-
def add_instance_method(name, location:)
|
|
58
|
+
def add_instance_method(name, location:, visibility: :public)
|
|
60
59
|
instance_methods.add(name.to_sym)
|
|
61
|
-
method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location)
|
|
60
|
+
method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location, visibility)
|
|
62
61
|
end
|
|
63
62
|
|
|
64
|
-
def add_class_method(name, location:)
|
|
63
|
+
def add_class_method(name, location:, visibility: :public)
|
|
65
64
|
class_methods.add(name.to_sym)
|
|
66
|
-
method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location)
|
|
65
|
+
method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location, visibility)
|
|
67
66
|
end
|
|
68
67
|
|
|
69
68
|
def add_mixin(kind, name)
|
|
70
69
|
mixins.fetch(kind).add(name)
|
|
71
70
|
end
|
|
71
|
+
|
|
72
|
+
# Rewrites the visibility of already-recorded definitions, for the
|
|
73
|
+
# <tt>private :foo, :bar</tt> form that names methods defined earlier.
|
|
74
|
+
def set_visibility(name, scope, visibility)
|
|
75
|
+
name = name.to_sym
|
|
76
|
+
method_definitions.map! do |definition|
|
|
77
|
+
definition.name == name && definition.scope == scope ? definition.with(visibility: visibility) : definition
|
|
78
|
+
end
|
|
79
|
+
end
|
|
72
80
|
end
|
|
73
81
|
|
|
74
|
-
Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence)
|
|
82
|
+
Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence, :receiver)
|
|
75
83
|
|
|
76
84
|
class Component
|
|
77
85
|
attr_reader :name, :files, :constants, :file_reasons, :constant_reasons
|
|
@@ -104,6 +112,8 @@ module ArchSpec
|
|
|
104
112
|
extends
|
|
105
113
|
].freeze
|
|
106
114
|
|
|
115
|
+
RESOLVED_ROOTS = %w[Object BasicObject].freeze
|
|
116
|
+
|
|
107
117
|
attr_reader :root, :files, :constants, :edges, :components
|
|
108
118
|
|
|
109
119
|
def initialize(root)
|
|
@@ -115,11 +125,10 @@ module ArchSpec
|
|
|
115
125
|
@components = {}
|
|
116
126
|
end
|
|
117
127
|
|
|
118
|
-
def add_file(path:,
|
|
128
|
+
def add_file(path:, parse_errors:, suppressions: [])
|
|
119
129
|
files[path] = SourceFile.new(
|
|
120
130
|
root: root,
|
|
121
131
|
path: path,
|
|
122
|
-
expected_constant: expected_constant,
|
|
123
132
|
parse_errors: parse_errors,
|
|
124
133
|
suppressions: suppressions
|
|
125
134
|
)
|
|
@@ -136,8 +145,8 @@ module ArchSpec
|
|
|
136
145
|
constant
|
|
137
146
|
end
|
|
138
147
|
|
|
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)
|
|
148
|
+
def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil)
|
|
149
|
+
edges << Edge.new(type, from_path, from_constant, normalize_constant(to), location, confidence, receiver)
|
|
141
150
|
end
|
|
142
151
|
|
|
143
152
|
def constants_named(name)
|
|
@@ -155,6 +164,12 @@ module ArchSpec
|
|
|
155
164
|
component.constants.flat_map { |constant_name| constants_named(constant_name) }.flat_map(&:method_definitions)
|
|
156
165
|
end
|
|
157
166
|
|
|
167
|
+
# Every method definition in the graph, across all constants. Used by
|
|
168
|
+
# project-wide naming rules that are not scoped to one component.
|
|
169
|
+
def method_definitions
|
|
170
|
+
constants.flat_map(&:method_definitions)
|
|
171
|
+
end
|
|
172
|
+
|
|
158
173
|
def assign_components(component_specs)
|
|
159
174
|
@components = {}
|
|
160
175
|
|
|
@@ -225,6 +240,39 @@ module ArchSpec
|
|
|
225
240
|
candidates.find { |candidate| constants_named(candidate).any? } || normalized
|
|
226
241
|
end
|
|
227
242
|
|
|
243
|
+
# Instance methods a constant responds to, walking resolvable superclasses
|
|
244
|
+
# and include/prepend mixins. Returns [methods, unresolved ancestor names];
|
|
245
|
+
# a non-empty second element means the answer is incomplete.
|
|
246
|
+
def effective_instance_methods(name, visited = Set.new)
|
|
247
|
+
normalized = normalize_constant(name)
|
|
248
|
+
return [Set.new, Set.new] if visited.include?(normalized)
|
|
249
|
+
|
|
250
|
+
visited.add(normalized)
|
|
251
|
+
return [Set.new, Set.new] if RESOLVED_ROOTS.include?(normalized)
|
|
252
|
+
|
|
253
|
+
nodes = constants_named(normalized)
|
|
254
|
+
return [Set.new, Set[normalized]] if nodes.empty?
|
|
255
|
+
|
|
256
|
+
methods = Set.new
|
|
257
|
+
unresolved = Set.new
|
|
258
|
+
|
|
259
|
+
nodes.each do |node|
|
|
260
|
+
methods.merge(node.instance_methods)
|
|
261
|
+
|
|
262
|
+
ancestors = node.mixins[:include].to_a + node.mixins[:prepend].to_a
|
|
263
|
+
ancestors << node.superclass if node.superclass
|
|
264
|
+
|
|
265
|
+
ancestors.each do |ancestor|
|
|
266
|
+
resolved_name = resolve_constant_reference(ancestor, node.name)
|
|
267
|
+
ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
|
|
268
|
+
methods.merge(ancestor_methods)
|
|
269
|
+
unresolved.merge(ancestor_unresolved)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
[methods, unresolved]
|
|
274
|
+
end
|
|
275
|
+
|
|
228
276
|
def component_dependency_pairs(only: nil)
|
|
229
277
|
allowed_sources = Array(only).compact.map(&:to_sym).to_set
|
|
230
278
|
pairs = Set.new
|
|
@@ -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
|