archspec 0.4.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 +7 -3
- data/lib/archspec/analyzer.rb +149 -60
- data/lib/archspec/architectures.rb +61 -32
- data/lib/archspec/cli.rb +1 -73
- data/lib/archspec/definition.rb +1 -6
- data/lib/archspec/dsl.rb +34 -43
- data/lib/archspec/formatters/explanation.rb +93 -0
- data/lib/archspec/model.rb +23 -10
- data/lib/archspec/rules/cycle_rule.rb +1 -1
- data/lib/archspec/rules/dependency_rules.rb +1 -1
- data/lib/archspec/rules/naming_rules.rb +209 -0
- data/lib/archspec/rules/protocol_rules.rb +1 -1
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +2 -2
- metadata +4 -4
- data/lib/archspec/presets.rb +0 -14
- data/lib/archspec/rules/zeitwerk_rule.rb +0 -51
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b5ac200fd85432184c5043ce7540e45cb3bcc0013a37ecd77d07c34aca51bfd8
|
|
4
|
+
data.tar.gz: 5d3e6b1558c796c6784505b9637b5fa58fb0da9d7b85fd30e71b7a26689da471
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 71b4a46c3423cd47b0317559aea01038431b29f0fa8cbd1f72a403cf5e4a3bb64bf7649412752110d7a49e5e59abbc387dd3e3ec1251a11a1f7219d6fdd05877
|
|
7
|
+
data.tar.gz: 28f23d54d05d3d16663a5c728aa55846b45f240913cdada2954672059d9193e895c7e651434351bef50f30731ad08e8b5660680e17d97b294846745f769e7f75
|
data/README.md
CHANGED
|
@@ -92,7 +92,7 @@ component :controllers, in: "app/controllers/**/*.rb"
|
|
|
92
92
|
component :models, in: "app/models/**/*.rb"
|
|
93
93
|
component :services, in: "app/services/**/*.rb"
|
|
94
94
|
|
|
95
|
-
controllers.
|
|
95
|
+
controllers.can_only_use :models, :services
|
|
96
96
|
models.cannot_use :controllers
|
|
97
97
|
services.cannot_call :render, :redirect_to, :params, :session
|
|
98
98
|
services.cannot_instantiate_and_invoke
|
|
@@ -114,13 +114,17 @@ architecture :cqrs,
|
|
|
114
114
|
- **Concerns:** a concern must not depend on the classes that include it
|
|
115
115
|
- **Layers:** dependency direction and cycles
|
|
116
116
|
- **Rails:** controller APIs kept out of models and services
|
|
117
|
-
- **Architectures:** Rails, vanilla Rails, layered, hexagonal, clean, modular monolith, CQRS,
|
|
117
|
+
- **Architectures:** Rails, vanilla Rails, layered, hexagonal, clean, modular monolith, CQRS, event-driven, and Ruby conventions bundles
|
|
118
118
|
- **Protocols:** required methods such as `resolve`, `perform`, or project-specific interfaces
|
|
119
|
+
- **Naming:** conventions on a component's public API, such as banning `get_`/`set_` or pairing `with_x` with `without_x`
|
|
119
120
|
- **Objects:** rules against one-shot `Something.new(...).whatever` command objects
|
|
120
|
-
- **Zeitwerk names:** conventional file names defining the expected constants
|
|
121
121
|
- **Empty components:** directories that must stay empty, like `app/services` in vanilla Rails
|
|
122
122
|
- **Suppressions:** narrow local exceptions with a reason
|
|
123
123
|
|
|
124
|
+
## Checking Zeitwerk Names
|
|
125
|
+
|
|
126
|
+
ArchSpec does not check Zeitwerk constant names. Zeitwerk does that itself. Add `Zeitwerk::Loader.eager_load_all` (or your loader's `eager_load`) to your test suite or CI. It raises on any file that does not define the constant its path implies, using your real inflector and ignores.
|
|
127
|
+
|
|
124
128
|
## Installation
|
|
125
129
|
|
|
126
130
|
Add ArchSpec to your Gemfile:
|
data/lib/archspec/analyzer.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'prism'
|
|
4
|
-
require 'pathname'
|
|
5
4
|
|
|
6
5
|
module ArchSpec
|
|
7
6
|
module Analyzer
|
|
@@ -15,7 +14,6 @@ module ArchSpec
|
|
|
15
14
|
result = Prism.parse_file(path)
|
|
16
15
|
graph.add_file(
|
|
17
16
|
path: path,
|
|
18
|
-
expected_constant: expected_constant_for(path, root, definition.inflections),
|
|
19
17
|
parse_errors: parse_errors_for(path, result.errors),
|
|
20
18
|
suppressions: suppressions_for(result.comments)
|
|
21
19
|
)
|
|
@@ -49,38 +47,6 @@ module ArchSpec
|
|
|
49
47
|
end.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }.to_set
|
|
50
48
|
end
|
|
51
49
|
|
|
52
|
-
def expected_constant_for(path, root, inflections = {})
|
|
53
|
-
relative = Pathname(path).relative_path_from(Pathname(root)).to_s
|
|
54
|
-
stem =
|
|
55
|
-
case relative
|
|
56
|
-
when %r{\Aapp/[^/]+/concerns/(.+)\.rb\z}
|
|
57
|
-
Regexp.last_match(1)
|
|
58
|
-
when %r{\Aapp/[^/]+/(.+)\.rb\z}
|
|
59
|
-
Regexp.last_match(1)
|
|
60
|
-
when %r{\Alib/(.+)\.rb\z}
|
|
61
|
-
Regexp.last_match(1)
|
|
62
|
-
when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/concerns/(.+)\.rb\z}
|
|
63
|
-
Regexp.last_match(1)
|
|
64
|
-
when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/(.+)\.rb\z}
|
|
65
|
-
Regexp.last_match(1)
|
|
66
|
-
else
|
|
67
|
-
return nil
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
camelize_path(stem, inflections)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Rails-style acronyms: inflections apply to whole path segments first,
|
|
74
|
-
# then to each snake_case word (inflect "api" => "API" fixes both api.rb
|
|
75
|
-
# and api_client.rb).
|
|
76
|
-
def camelize_path(path, inflections = {})
|
|
77
|
-
path.split('/').map do |part|
|
|
78
|
-
inflections[part] || part.split('_').map do |word|
|
|
79
|
-
inflections[word] || (word[0] ? word[0].upcase + word[1..] : word)
|
|
80
|
-
end.join
|
|
81
|
-
end.join('::')
|
|
82
|
-
end
|
|
83
|
-
|
|
84
50
|
def suppressions_for(comments)
|
|
85
51
|
SuppressionParser.parse(comments)
|
|
86
52
|
end
|
|
@@ -175,24 +141,31 @@ module ArchSpec
|
|
|
175
141
|
attr_accessor: %i[reader writer]
|
|
176
142
|
}.freeze
|
|
177
143
|
|
|
178
|
-
def visit(graph, path, node, current_constant: nil, namespace: [])
|
|
144
|
+
def visit(graph, path, node, current_constant: nil, namespace: [], visibility: :public, default_scope: :instance)
|
|
179
145
|
return unless node
|
|
180
146
|
|
|
181
147
|
case node
|
|
182
148
|
when Prism::ProgramNode, Prism::StatementsNode
|
|
183
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
149
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
150
|
+
visibility: visibility, default_scope: default_scope)
|
|
184
151
|
when Prism::ClassNode
|
|
185
152
|
visit_class(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
186
153
|
when Prism::ModuleNode
|
|
187
154
|
visit_module(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
155
|
+
when Prism::SingletonClassNode
|
|
156
|
+
visit_singleton_class(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
157
|
+
visibility: visibility, default_scope: default_scope)
|
|
188
158
|
when Prism::DefNode
|
|
189
|
-
visit_def(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
159
|
+
visit_def(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
160
|
+
visibility: visibility, default_scope: default_scope)
|
|
190
161
|
when Prism::CallNode
|
|
191
|
-
visit_call(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
162
|
+
visit_call(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
163
|
+
visibility: visibility, default_scope: default_scope)
|
|
192
164
|
when Prism::ConstantPathNode, Prism::ConstantReadNode
|
|
193
165
|
add_constant_reference(graph, path, node, current_constant)
|
|
194
166
|
else
|
|
195
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
167
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
168
|
+
visibility: visibility, default_scope: default_scope)
|
|
196
169
|
end
|
|
197
170
|
end
|
|
198
171
|
|
|
@@ -229,7 +202,7 @@ module ArchSpec
|
|
|
229
202
|
end
|
|
230
203
|
end
|
|
231
204
|
|
|
232
|
-
|
|
205
|
+
visit_constant_body(graph, path, constant, node.body, constant.name.split('::'))
|
|
233
206
|
end
|
|
234
207
|
|
|
235
208
|
def visit_module(graph, path, node, current_constant:, namespace:)
|
|
@@ -241,17 +214,67 @@ module ArchSpec
|
|
|
241
214
|
location: SourceLocation.from_prism(path, node.location)
|
|
242
215
|
)
|
|
243
216
|
|
|
244
|
-
|
|
217
|
+
visit_constant_body(graph, path, constant, node.body, constant.name.split('::'))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A +class << self+ (or +class << SomeConstant+) block defines methods on a
|
|
221
|
+
# constant's singleton, so they are class methods, and +private+ inside it
|
|
222
|
+
# applies to them. Walk the body with +default_scope: :class+ against the
|
|
223
|
+
# target constant. An unknown target (+class << variable+) still has its
|
|
224
|
+
# body visited for edges, but no methods are attributed.
|
|
225
|
+
def visit_singleton_class(graph, path, node, current_constant:, namespace:, visibility:, default_scope:)
|
|
226
|
+
target = singleton_target(node.expression, current_constant)
|
|
227
|
+
constant = target && graph.constants_named(target).find { |candidate| candidate.path == path }
|
|
228
|
+
|
|
229
|
+
if constant
|
|
230
|
+
visit_constant_body(graph, path, constant, node.body, namespace, default_scope: :class)
|
|
231
|
+
else
|
|
232
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
233
|
+
visibility: visibility, default_scope: default_scope)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def singleton_target(expression, current_constant)
|
|
238
|
+
return current_constant if expression.is_a?(Prism::SelfNode)
|
|
239
|
+
|
|
240
|
+
constant_reference_name(expression) if constant_node?(expression)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Walks a class or module body in source order, tracking method visibility
|
|
244
|
+
# so +private+/+protected+ and their inline and symbol-list forms mark the
|
|
245
|
+
# methods they cover. +default_scope+ is +:class+ inside a singleton class,
|
|
246
|
+
# so bare +def+s there are class methods. Every statement is still handed to
|
|
247
|
+
# +visit+, so all other facts (calls, references, mixins) are recorded
|
|
248
|
+
# exactly as before.
|
|
249
|
+
def visit_constant_body(graph, path, constant, body, namespace, default_scope: :instance)
|
|
250
|
+
return unless body
|
|
251
|
+
|
|
252
|
+
unless body.is_a?(Prism::StatementsNode)
|
|
253
|
+
return visit(graph, path, body, current_constant: constant.name, namespace: namespace,
|
|
254
|
+
default_scope: default_scope)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
visibility = :public
|
|
258
|
+
body.body.each do |statement|
|
|
259
|
+
if (modifier = visibility_modifier(statement))
|
|
260
|
+
visibility = apply_visibility_modifier(graph, path, constant, statement, namespace, visibility, modifier,
|
|
261
|
+
default_scope)
|
|
262
|
+
else
|
|
263
|
+
visit(graph, path, statement, current_constant: constant.name, namespace: namespace,
|
|
264
|
+
visibility: visibility, default_scope: default_scope)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
245
267
|
end
|
|
246
268
|
|
|
247
|
-
def visit_def(graph, path, node, current_constant:, namespace:)
|
|
269
|
+
def visit_def(graph, path, node, current_constant:, namespace:, visibility: :public, default_scope: :instance)
|
|
248
270
|
if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
|
|
249
271
|
candidate.path == path
|
|
250
272
|
end)
|
|
251
|
-
|
|
252
|
-
|
|
273
|
+
location = SourceLocation.from_prism(path, node.location)
|
|
274
|
+
if node.receiver || default_scope == :class
|
|
275
|
+
constant.add_class_method(node.name, location: location, visibility: visibility)
|
|
253
276
|
else
|
|
254
|
-
constant.add_instance_method(node.name, location:
|
|
277
|
+
constant.add_instance_method(node.name, location: location, visibility: visibility)
|
|
255
278
|
end
|
|
256
279
|
end
|
|
257
280
|
|
|
@@ -269,10 +292,10 @@ module ArchSpec
|
|
|
269
292
|
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
270
293
|
end
|
|
271
294
|
|
|
272
|
-
def visit_call(graph, path, node, current_constant:, namespace:)
|
|
295
|
+
def visit_call(graph, path, node, current_constant:, namespace:, visibility: :public, default_scope: :instance)
|
|
273
296
|
unless node.message
|
|
274
|
-
return visit_children(graph, path, node, current_constant: current_constant,
|
|
275
|
-
|
|
297
|
+
return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
298
|
+
visibility: visibility, default_scope: default_scope)
|
|
276
299
|
end
|
|
277
300
|
|
|
278
301
|
message = node.message.to_sym
|
|
@@ -298,7 +321,7 @@ module ArchSpec
|
|
|
298
321
|
)
|
|
299
322
|
end
|
|
300
323
|
|
|
301
|
-
record_generated_methods(graph, path, node, message, current_constant, location)
|
|
324
|
+
record_generated_methods(graph, path, node, message, current_constant, location, visibility, default_scope)
|
|
302
325
|
|
|
303
326
|
if (edge_type = MIXIN_MESSAGES[message])
|
|
304
327
|
constant_arguments(node).each do |constant_name|
|
|
@@ -338,7 +361,8 @@ module ArchSpec
|
|
|
338
361
|
)
|
|
339
362
|
end
|
|
340
363
|
|
|
341
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace
|
|
364
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
365
|
+
visibility: visibility, default_scope: default_scope)
|
|
342
366
|
end
|
|
343
367
|
|
|
344
368
|
def add_constant_reference(graph, path, node, current_constant)
|
|
@@ -354,9 +378,62 @@ module ArchSpec
|
|
|
354
378
|
)
|
|
355
379
|
end
|
|
356
380
|
|
|
357
|
-
def visit_children(graph, path, node, current_constant:, namespace:)
|
|
381
|
+
def visit_children(graph, path, node, current_constant:, namespace:, visibility: :public, default_scope: :instance)
|
|
358
382
|
node.child_nodes.compact.each do |child|
|
|
359
|
-
visit(graph, path, child, current_constant: current_constant, namespace: namespace
|
|
383
|
+
visit(graph, path, child, current_constant: current_constant, namespace: namespace,
|
|
384
|
+
visibility: visibility, default_scope: default_scope)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Maps a visibility call to [visibility, forced_scope]. A nil forced_scope
|
|
389
|
+
# means the call follows the context (instance methods in a class body,
|
|
390
|
+
# class methods inside +class << self+); +*_class_method+ always targets
|
|
391
|
+
# class methods.
|
|
392
|
+
VISIBILITY_MODIFIERS = {
|
|
393
|
+
private: [:private, nil],
|
|
394
|
+
protected: [:protected, nil],
|
|
395
|
+
public: [:public, nil],
|
|
396
|
+
private_class_method: [:private, :class],
|
|
397
|
+
public_class_method: [:public, :class]
|
|
398
|
+
}.freeze
|
|
399
|
+
|
|
400
|
+
# The [visibility, forced_scope] a bare visibility call sets, or nil when
|
|
401
|
+
# the node is not one. Only receiverless calls count, so +obj.private+ is
|
|
402
|
+
# ignored.
|
|
403
|
+
def visibility_modifier(node)
|
|
404
|
+
return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
405
|
+
|
|
406
|
+
VISIBILITY_MODIFIERS[node.message&.to_sym]
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Applies a visibility call and returns the default visibility for the
|
|
410
|
+
# statements that follow it. A bare +private+ changes that default; the
|
|
411
|
+
# inline (+private def foo+) and symbol-list (+private :foo+) forms mark
|
|
412
|
+
# only the methods they name and leave the default unchanged.
|
|
413
|
+
def apply_visibility_modifier(graph, path, constant, node, namespace, current, spec, default_scope)
|
|
414
|
+
visibility, forced_scope = spec
|
|
415
|
+
scope = forced_scope || default_scope
|
|
416
|
+
arguments = node.arguments&.arguments || []
|
|
417
|
+
definitions = arguments.select { |argument| argument.is_a?(Prism::DefNode) }
|
|
418
|
+
names = arguments.select { |argument| argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode) }
|
|
419
|
+
|
|
420
|
+
if definitions.any?
|
|
421
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
422
|
+
visibility: visibility, default_scope: default_scope)
|
|
423
|
+
current
|
|
424
|
+
elsif names.any?
|
|
425
|
+
names.each { |name| constant.set_visibility(name.unescaped.to_sym, scope, visibility) }
|
|
426
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
427
|
+
visibility: current, default_scope: default_scope)
|
|
428
|
+
current
|
|
429
|
+
elsif forced_scope.nil?
|
|
430
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
431
|
+
visibility: visibility, default_scope: default_scope)
|
|
432
|
+
visibility
|
|
433
|
+
else
|
|
434
|
+
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
435
|
+
visibility: current, default_scope: default_scope)
|
|
436
|
+
current
|
|
360
437
|
end
|
|
361
438
|
end
|
|
362
439
|
|
|
@@ -376,25 +453,37 @@ module ArchSpec
|
|
|
376
453
|
end || []
|
|
377
454
|
end
|
|
378
455
|
|
|
379
|
-
# attr_
|
|
380
|
-
# them, a class calling its own reader looks like a foreign call.
|
|
381
|
-
def record_generated_methods(graph, path, node, message, current_constant, location)
|
|
456
|
+
# attr_*, Rails attribute, and unprefixed delegate calls define methods;
|
|
457
|
+
# without them, a class calling its own reader looks like a foreign call.
|
|
458
|
+
def record_generated_methods(graph, path, node, message, current_constant, location, visibility, default_scope)
|
|
382
459
|
return unless current_constant && node.receiver.nil?
|
|
383
|
-
return unless ATTR_MESSAGES.key?(message) ||
|
|
460
|
+
return unless ATTR_MESSAGES.key?(message) || %i[attribute delegate].include?(message)
|
|
384
461
|
|
|
385
462
|
constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path }
|
|
386
463
|
return unless constant
|
|
387
464
|
|
|
388
|
-
names =
|
|
465
|
+
names = generated_method_names(constant, node, message)
|
|
389
466
|
return if message == :delegate && keyword_argument?(node, :prefix)
|
|
390
467
|
|
|
468
|
+
adder = default_scope == :class ? :add_class_method : :add_instance_method
|
|
391
469
|
names.each do |name|
|
|
392
|
-
kinds = ATTR_MESSAGES.fetch(message, %i[reader])
|
|
393
|
-
constant.
|
|
394
|
-
constant.
|
|
470
|
+
kinds = message == :attribute ? %i[reader writer] : ATTR_MESSAGES.fetch(message, %i[reader])
|
|
471
|
+
constant.public_send(adder, name, location: location, visibility: visibility) if kinds.include?(:reader)
|
|
472
|
+
constant.public_send(adder, :"#{name}=", location: location, visibility: visibility) if kinds.include?(:writer)
|
|
395
473
|
end
|
|
396
474
|
end
|
|
397
475
|
|
|
476
|
+
# Active Record and Active Model take one attribute name followed by an
|
|
477
|
+
# optional type, which may itself be a symbol. CurrentAttributes instead
|
|
478
|
+
# accepts any number of names, so retain every literal name there.
|
|
479
|
+
def generated_method_names(constant, node, message)
|
|
480
|
+
names = symbol_arguments(node)
|
|
481
|
+
return names unless message == :attribute
|
|
482
|
+
return names if constant.superclass == 'ActiveSupport::CurrentAttributes'
|
|
483
|
+
|
|
484
|
+
names.first(1)
|
|
485
|
+
end
|
|
486
|
+
|
|
398
487
|
def symbol_arguments(node)
|
|
399
488
|
node.arguments&.arguments&.filter_map do |argument|
|
|
400
489
|
argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
|
|
@@ -11,30 +11,33 @@ module ArchSpec
|
|
|
11
11
|
# Every preset accepts overrides for its directories, so you can keep the
|
|
12
12
|
# shape while pointing at your own paths. The presets are:
|
|
13
13
|
#
|
|
14
|
-
# +:rails+ (aliases +:rails_mvc+, +:rails_way+)
|
|
15
|
-
# controller APIs out of models and services. Options
|
|
14
|
+
# - +:rails+ (aliases +:rails_mvc+, +:rails_way+): conventional MVC that keeps
|
|
15
|
+
# controller APIs out of models and services. Options +components:+,
|
|
16
16
|
# +controller_api:+, +share_helpers:+.
|
|
17
|
-
# +:rails_strict
|
|
18
|
-
#
|
|
19
|
-
# +:vanilla_rails
|
|
20
|
-
# style
|
|
21
|
-
# and the concern independence check. Options
|
|
17
|
+
# - +:rails_strict+: +:rails+ plus a cycle check and a concern independence
|
|
18
|
+
# check. Adds option +concerns:+.
|
|
19
|
+
# - +:vanilla_rails+: +:rails+ plus empty-directory rules for the 37signals
|
|
20
|
+
# style (forbidding +app/services+, +app/forms+, +app/policies+, and more)
|
|
21
|
+
# and the concern independence check. Options +components:+, +empty:+,
|
|
22
22
|
# +controller_api:+, +share_helpers:+, +concerns:+.
|
|
23
|
-
# +:layered+ (alias +:rails_layered+)
|
|
24
|
-
# inward, with a cycle check. Option
|
|
25
|
-
# +:hexagonal+ (alias +:rails_hexagonal+)
|
|
26
|
-
# domain away from adapters. Options
|
|
23
|
+
# - +:layered+ (alias +:rails_layered+): ordered layers that may only depend
|
|
24
|
+
# inward, with a cycle check. Option +layers:+ (order matters).
|
|
25
|
+
# - +:hexagonal+ (alias +:rails_hexagonal+): ports and adapters, keeping the
|
|
26
|
+
# domain away from adapters. Options +application:+, +domain:+, +ports:+,
|
|
27
27
|
# +adapters:+.
|
|
28
|
-
# +:clean+ (alias +:rails_clean+)
|
|
28
|
+
# - +:clean+ (alias +:rails_clean+): clean architecture layers. Options
|
|
29
29
|
# +frameworks:+, +interface_adapters:+, +use_cases:+, +entities:+.
|
|
30
|
-
# +:modular_monolith+ (alias +:bounded_contexts+)
|
|
31
|
-
# per-package allowlists and optional public APIs. Options
|
|
30
|
+
# - +:modular_monolith+ (alias +:bounded_contexts+): named packages with
|
|
31
|
+
# per-package allowlists and optional public APIs. Options +components:+
|
|
32
32
|
# (required), +allow:+, +public:+.
|
|
33
|
-
# +:cqrs+ (alias +:rails_cqrs+)
|
|
34
|
-
# writes out of queries. Options
|
|
33
|
+
# - +:cqrs+ (alias +:rails_cqrs+): separates commands from queries and keeps
|
|
34
|
+
# writes out of queries. Options +commands:+, +queries:+, +read_models:+,
|
|
35
35
|
# +mutating_methods:+.
|
|
36
|
-
# +:event_driven+ (alias +:rails_event_driven+)
|
|
37
|
-
# subscribers. Options
|
|
36
|
+
# - +:event_driven+ (alias +:rails_event_driven+): events, publishers, and
|
|
37
|
+
# subscribers. Options +events:+, +publishers:+, +subscribers:+.
|
|
38
|
+
# - +:ruby_conventions+: generic Ruby naming idioms (no +get_+/+set_+, no +is_+
|
|
39
|
+
# prefix), applied project-wide. Adds no components, so it composes with any
|
|
40
|
+
# other architecture. No options.
|
|
38
41
|
#
|
|
39
42
|
# See the guides at https://archspecrb.dev/architectures/ for each in depth.
|
|
40
43
|
module Architectures
|
|
@@ -149,6 +152,8 @@ module ArchSpec
|
|
|
149
152
|
cqrs(dsl, **with_defaults(DEFAULT_CQRS, options))
|
|
150
153
|
when :event_driven, :rails_event_driven
|
|
151
154
|
event_driven(dsl, **with_defaults(DEFAULT_EVENT_DRIVEN, options))
|
|
155
|
+
when :ruby_conventions
|
|
156
|
+
ruby_conventions(dsl)
|
|
152
157
|
else
|
|
153
158
|
raise Error, "Unknown ArchSpec architecture: #{name.inspect}"
|
|
154
159
|
end
|
|
@@ -159,7 +164,7 @@ module ArchSpec
|
|
|
159
164
|
define_components(dsl, components)
|
|
160
165
|
|
|
161
166
|
forbidden = share_helpers ? %i[controllers] : %i[controllers helpers]
|
|
162
|
-
proxy_for(dsl, :controllers).
|
|
167
|
+
proxy_for(dsl, :controllers).can_only_use(*components.keys & %i[models services helpers mailers jobs])
|
|
163
168
|
proxy_for(dsl, :models).cannot_use(*components.keys & forbidden)
|
|
164
169
|
proxy_for(dsl, :services).cannot_use(*components.keys & forbidden)
|
|
165
170
|
|
|
@@ -172,8 +177,7 @@ module ArchSpec
|
|
|
172
177
|
def rails_strict(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false, concerns: DEFAULT_CONCERNS)
|
|
173
178
|
components = normalize_map(components)
|
|
174
179
|
rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
175
|
-
dsl.
|
|
176
|
-
dsl.no_cycles!(among: components.keys)
|
|
180
|
+
dsl.no_cycles(among: components.keys)
|
|
177
181
|
independent_concerns(dsl, concerns)
|
|
178
182
|
end
|
|
179
183
|
|
|
@@ -195,10 +199,10 @@ module ArchSpec
|
|
|
195
199
|
|
|
196
200
|
names.each_with_index do |name, index|
|
|
197
201
|
allowed = names[(index + 1)..] || []
|
|
198
|
-
proxy_for(dsl, name).
|
|
202
|
+
proxy_for(dsl, name).can_only_use(*allowed)
|
|
199
203
|
end
|
|
200
204
|
|
|
201
|
-
dsl.no_cycles
|
|
205
|
+
dsl.no_cycles(among: names)
|
|
202
206
|
end
|
|
203
207
|
|
|
204
208
|
def hexagonal(dsl, application:, domain:, ports:, adapters:)
|
|
@@ -210,11 +214,11 @@ module ArchSpec
|
|
|
210
214
|
)
|
|
211
215
|
define_components(dsl, roles)
|
|
212
216
|
|
|
213
|
-
proxy_for(dsl, :application).
|
|
217
|
+
proxy_for(dsl, :application).can_only_use :domain, :ports
|
|
214
218
|
proxy_for(dsl, :domain).cannot_use :adapters
|
|
215
219
|
proxy_for(dsl, :ports).cannot_use :adapters
|
|
216
|
-
proxy_for(dsl, :adapters).
|
|
217
|
-
dsl.no_cycles
|
|
220
|
+
proxy_for(dsl, :adapters).can_only_use :application, :domain, :ports
|
|
221
|
+
dsl.no_cycles(among: roles.keys)
|
|
218
222
|
end
|
|
219
223
|
|
|
220
224
|
def clean(dsl, frameworks:, interface_adapters:, use_cases:, entities:)
|
|
@@ -235,13 +239,13 @@ module ArchSpec
|
|
|
235
239
|
|
|
236
240
|
components.each_key do |name|
|
|
237
241
|
allowed = Array(allow[name] || allow[name.to_s])
|
|
238
|
-
proxy_for(dsl, name).
|
|
242
|
+
proxy_for(dsl, name).can_only_use(*allowed)
|
|
239
243
|
|
|
240
244
|
patterns = Array(public[name] || public[name.to_s])
|
|
241
245
|
proxy_for(dsl, name).public_api(*patterns) if patterns.any?
|
|
242
246
|
end
|
|
243
247
|
|
|
244
|
-
dsl.no_cycles
|
|
248
|
+
dsl.no_cycles(among: components.keys)
|
|
245
249
|
end
|
|
246
250
|
|
|
247
251
|
def cqrs(dsl, commands:, queries:, read_models: nil, mutating_methods: MUTATING_METHODS)
|
|
@@ -252,7 +256,7 @@ module ArchSpec
|
|
|
252
256
|
proxy_for(dsl, :commands).cannot_use :queries
|
|
253
257
|
proxy_for(dsl, :queries).cannot_use :commands
|
|
254
258
|
proxy_for(dsl, :queries).cannot_call(*mutating_methods)
|
|
255
|
-
dsl.no_cycles
|
|
259
|
+
dsl.no_cycles(among: components.keys)
|
|
256
260
|
end
|
|
257
261
|
|
|
258
262
|
def event_driven(dsl, events:, publishers:, subscribers:)
|
|
@@ -260,13 +264,36 @@ module ArchSpec
|
|
|
260
264
|
define_components(dsl, roles)
|
|
261
265
|
|
|
262
266
|
proxy_for(dsl, :events).cannot_use :publishers, :subscribers
|
|
263
|
-
proxy_for(dsl, :publishers).
|
|
264
|
-
proxy_for(dsl, :subscribers).
|
|
265
|
-
dsl.no_cycles
|
|
267
|
+
proxy_for(dsl, :publishers).can_only_use :events
|
|
268
|
+
proxy_for(dsl, :subscribers).can_only_use :events
|
|
269
|
+
dsl.no_cycles(among: roles.keys)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Applies the generic Ruby naming idioms project-wide: no +get_+/+set_+
|
|
273
|
+
# accessors and no +is_+ predicate prefix. Adds no components, so it composes
|
|
274
|
+
# with any other architecture. Project-specific conventions (the +with_x+ /
|
|
275
|
+
# +without_x+ pairing, the +supports_*?+ ban) stay opt-in through the
|
|
276
|
+
# +method_names.matching(...)+ primitives.
|
|
277
|
+
def ruby_conventions(dsl)
|
|
278
|
+
%i[instance class].each do |scope|
|
|
279
|
+
forbid_name(dsl, /\A(get|set)_/, 'use attr_ readers and writers or plain names, not get_/set_', scope: scope)
|
|
280
|
+
forbid_name(dsl, /\Ais_/, 'name predicates with a trailing ? and no is_ prefix (has_ is fine)', scope: scope)
|
|
281
|
+
end
|
|
266
282
|
end
|
|
267
283
|
|
|
268
284
|
private
|
|
269
285
|
|
|
286
|
+
def forbid_name(dsl, regex, reason, scope:)
|
|
287
|
+
dsl.rule(
|
|
288
|
+
Rules::NamingRule.new(
|
|
289
|
+
source: nil,
|
|
290
|
+
selector: Rules::Naming::NameSelector.new(regex),
|
|
291
|
+
constraint: Rules::Naming::Forbidden.new(because: reason),
|
|
292
|
+
scope: scope
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
|
|
270
297
|
def with_defaults(defaults, options)
|
|
271
298
|
defaults.merge(options)
|
|
272
299
|
end
|
|
@@ -302,6 +329,8 @@ module ArchSpec
|
|
|
302
329
|
return unless pattern
|
|
303
330
|
|
|
304
331
|
dsl.component(:concerns, in: pattern).cannot_reference_includers
|
|
332
|
+
# Controllers carry an allowlist, so let them include concerns too.
|
|
333
|
+
proxy_for(dsl, :controllers).can_only_use(:concerns)
|
|
305
334
|
end
|
|
306
335
|
end
|
|
307
336
|
end
|
data/lib/archspec/cli.rb
CHANGED
|
@@ -107,7 +107,7 @@ module ArchSpec
|
|
|
107
107
|
|
|
108
108
|
definition, root = load_definition(options[:config])
|
|
109
109
|
graph = Analyzer.analyze(definition, root: root)
|
|
110
|
-
|
|
110
|
+
Formatters::Explanation.print(output, graph: graph, subject: subject)
|
|
111
111
|
0
|
|
112
112
|
end
|
|
113
113
|
|
|
@@ -155,78 +155,6 @@ module ArchSpec
|
|
|
155
155
|
end
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
-
def explain_subject(output, graph, subject)
|
|
159
|
-
path = File.expand_path(subject, graph.root)
|
|
160
|
-
|
|
161
|
-
if graph.files.key?(path)
|
|
162
|
-
file = graph.files.fetch(path)
|
|
163
|
-
output.puts file.relative_path
|
|
164
|
-
output.puts " expected constant: #{file.expected_constant || '(none)'}"
|
|
165
|
-
output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(', ')}"
|
|
166
|
-
output_parse_errors(output, file)
|
|
167
|
-
output_component_reasons(output, graph.component_assignment_reasons_for_path(path))
|
|
168
|
-
output_suppressions(output, file)
|
|
169
|
-
output.puts ' outgoing facts:'
|
|
170
|
-
|
|
171
|
-
graph.edges.select { |edge| edge.from_path == path }.each do |edge|
|
|
172
|
-
output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
|
|
173
|
-
end
|
|
174
|
-
else
|
|
175
|
-
constants = graph.constants_named(subject)
|
|
176
|
-
raise Error, "No file or constant found for #{subject.inspect}" if constants.empty?
|
|
177
|
-
|
|
178
|
-
constants.each do |constant|
|
|
179
|
-
output.puts constant.name
|
|
180
|
-
output.puts " kind: #{constant.kind}"
|
|
181
|
-
output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
|
|
182
|
-
output_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
|
|
183
|
-
output.puts " superclass: #{constant.superclass || '(none)'}"
|
|
184
|
-
output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(', ')}"
|
|
185
|
-
output.puts " class methods: #{constant.class_methods.to_a.sort.join(', ')}"
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def output_component_reasons(output, assignments)
|
|
191
|
-
if assignments.empty?
|
|
192
|
-
output.puts ' components: (none)'
|
|
193
|
-
return
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
output.puts ' components:'
|
|
197
|
-
assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
|
|
198
|
-
output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def output_suppressions(output, file)
|
|
203
|
-
return if file.suppressions.empty?
|
|
204
|
-
|
|
205
|
-
output.puts ' suppressions:'
|
|
206
|
-
file.suppressions.each do |suppression|
|
|
207
|
-
line_range =
|
|
208
|
-
if suppression.end_line == Float::INFINITY
|
|
209
|
-
"#{suppression.start_line}-EOF"
|
|
210
|
-
elsif suppression.start_line == suppression.end_line
|
|
211
|
-
suppression.start_line
|
|
212
|
-
else
|
|
213
|
-
"#{suppression.start_line}-#{suppression.end_line}"
|
|
214
|
-
end
|
|
215
|
-
rule = suppression.rule || '*'
|
|
216
|
-
reason = suppression.reason ? " -- #{suppression.reason}" : ''
|
|
217
|
-
output.puts " #{rule} on line #{line_range}#{reason}"
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def output_parse_errors(output, file)
|
|
222
|
-
return if file.parse_errors.empty?
|
|
223
|
-
|
|
224
|
-
output.puts ' parse errors:'
|
|
225
|
-
file.parse_errors.each do |parse_error|
|
|
226
|
-
output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
|
|
230
158
|
def usage
|
|
231
159
|
<<~TEXT
|
|
232
160
|
Usage:
|
data/lib/archspec/definition.rb
CHANGED
|
@@ -22,7 +22,7 @@ module ArchSpec
|
|
|
22
22
|
].freeze
|
|
23
23
|
|
|
24
24
|
attr_accessor :name, :root_path, :todo_path, :base_dir
|
|
25
|
-
attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
|
|
25
|
+
attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
|
|
26
26
|
|
|
27
27
|
def initialize(name = nil)
|
|
28
28
|
@name = name
|
|
@@ -33,11 +33,6 @@ module ArchSpec
|
|
|
33
33
|
@ignore_patterns = DEFAULT_IGNORE_PATTERNS.dup
|
|
34
34
|
@component_specs = {}
|
|
35
35
|
@rules = []
|
|
36
|
-
@inflections = {}
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def add_inflections(map)
|
|
40
|
-
@inflections.merge!(map.to_h.transform_keys(&:to_s).transform_values(&:to_s))
|
|
41
36
|
end
|
|
42
37
|
|
|
43
38
|
def add_source_patterns(patterns)
|