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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25ea714cd768976e860df4f0dbaa9ff477e381096f95b98787a0a645fc0cd776
|
|
4
|
+
data.tar.gz: 527621e2bb18514433cf26ede594f2cafa2699d0d95e2d3fa4d9e8b9e4aac354
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9c83e97692a44bbf72391593e695bc4c8a7859d148508f4cb8cf490a55e51a8de98d0df8ab0d37bf8bf13fcdb3e4707f9325850a39b9a33ea678a0d0aa7f3b2
|
|
7
|
+
data.tar.gz: ec19d8b85144cee92cd7bd2c968d848aef39feccdda87b56849dab7ac706543fec8041bee524ab0c5532193b423442f6ef3eec00d6d52f6301ec3e960b5cf1b8
|
data/README.md
CHANGED
|
@@ -67,7 +67,7 @@ Keep a hexagonal core away from adapters:
|
|
|
67
67
|
architecture :hexagonal
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
Check a modular monolith:
|
|
70
|
+
Check a modular monolith, and make packs go through a public API:
|
|
71
71
|
|
|
72
72
|
```ruby
|
|
73
73
|
architecture :modular_monolith,
|
|
@@ -79,6 +79,9 @@ architecture :modular_monolith,
|
|
|
79
79
|
allow: {
|
|
80
80
|
billing: %i[shared],
|
|
81
81
|
catalog: %i[shared]
|
|
82
|
+
},
|
|
83
|
+
public: {
|
|
84
|
+
billing: "packs/billing/app/public/**/*.rb"
|
|
82
85
|
}
|
|
83
86
|
```
|
|
84
87
|
|
|
@@ -106,7 +109,9 @@ architecture :cqrs,
|
|
|
106
109
|
|
|
107
110
|
## What It Checks
|
|
108
111
|
|
|
109
|
-
- **Dependencies:** allowed and forbidden references between components
|
|
112
|
+
- **Dependencies:** allowed and forbidden references between components, in both directions
|
|
113
|
+
- **Privacy:** other components must go through a component's public API
|
|
114
|
+
- **Concerns:** a concern must not depend on the classes that include it
|
|
110
115
|
- **Layers:** dependency direction and cycles
|
|
111
116
|
- **Rails:** controller APIs kept out of models and services
|
|
112
117
|
- **Architectures:** Rails, vanilla Rails, layered, hexagonal, clean, modular monolith, CQRS, and event-driven bundles
|
|
@@ -150,7 +155,7 @@ bundle exec archspec check
|
|
|
150
155
|
bundle exec archspec init
|
|
151
156
|
bundle exec archspec check
|
|
152
157
|
bundle exec archspec check --format json
|
|
153
|
-
bundle exec archspec check --update-
|
|
158
|
+
bundle exec archspec check --update-todo
|
|
154
159
|
bundle exec archspec explain app/models/user.rb
|
|
155
160
|
```
|
|
156
161
|
|
|
@@ -161,12 +166,15 @@ facts ArchSpec found.
|
|
|
161
166
|
|
|
162
167
|
Generated code should pass the same architecture checks as hand-written code.
|
|
163
168
|
|
|
164
|
-
After an AI-assisted change:
|
|
169
|
+
After an AI-assisted change, check the whole project, or just the files that changed:
|
|
165
170
|
|
|
166
171
|
```sh
|
|
167
172
|
bundle exec archspec check
|
|
173
|
+
bundle exec archspec check app/models/user.rb app/services
|
|
168
174
|
```
|
|
169
175
|
|
|
176
|
+
Passing paths still analyzes the project so dependencies resolve, but reports only violations in those paths. This keeps the loop tight after each edit.
|
|
177
|
+
|
|
170
178
|
If it fails, read the evidence before changing the spec:
|
|
171
179
|
|
|
172
180
|
```text
|
|
@@ -179,16 +187,17 @@ If it fails, read the evidence before changing the spec:
|
|
|
179
187
|
Most failures should be fixed in the generated code. Update the spec only when
|
|
180
188
|
the architecture decision itself has changed.
|
|
181
189
|
|
|
182
|
-
##
|
|
190
|
+
## Todo and Suppressions
|
|
183
191
|
|
|
184
|
-
Use a
|
|
192
|
+
Use a todo file when adopting ArchSpec in an existing app. It records the current
|
|
193
|
+
violations so they stop failing the build, leaving a list to burn down:
|
|
185
194
|
|
|
186
195
|
```ruby
|
|
187
|
-
|
|
196
|
+
todo "archspec_todo.yml"
|
|
188
197
|
```
|
|
189
198
|
|
|
190
199
|
```sh
|
|
191
|
-
bundle exec archspec check --update-
|
|
200
|
+
bundle exec archspec check --update-todo
|
|
192
201
|
```
|
|
193
202
|
|
|
194
203
|
Use local suppressions for deliberate exceptions:
|
|
@@ -210,6 +219,12 @@ This repository checks its own architecture:
|
|
|
210
219
|
bundle exec rake architecture
|
|
211
220
|
```
|
|
212
221
|
|
|
222
|
+
It also runs against pinned checkouts of large real-world Rails apps to catch crashes and false positives before they ship:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
bundle exec rake torture
|
|
226
|
+
```
|
|
227
|
+
|
|
213
228
|
## License
|
|
214
229
|
|
|
215
230
|
Released under the MIT License.
|
data/lib/archspec/analyzer.rb
CHANGED
|
@@ -15,7 +15,7 @@ module ArchSpec
|
|
|
15
15
|
result = Prism.parse_file(path)
|
|
16
16
|
graph.add_file(
|
|
17
17
|
path: path,
|
|
18
|
-
expected_constant: expected_constant_for(path, root),
|
|
18
|
+
expected_constant: expected_constant_for(path, root, definition.inflections),
|
|
19
19
|
parse_errors: parse_errors_for(path, result.errors),
|
|
20
20
|
suppressions: suppressions_for(result.comments)
|
|
21
21
|
)
|
|
@@ -49,7 +49,7 @@ module ArchSpec
|
|
|
49
49
|
end.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }.to_set
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
def expected_constant_for(path, root)
|
|
52
|
+
def expected_constant_for(path, root, inflections = {})
|
|
53
53
|
relative = Pathname(path).relative_path_from(Pathname(root)).to_s
|
|
54
54
|
stem =
|
|
55
55
|
case relative
|
|
@@ -67,12 +67,17 @@ module ArchSpec
|
|
|
67
67
|
return nil
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
camelize_path(stem)
|
|
70
|
+
camelize_path(stem, inflections)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
|
|
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 = {})
|
|
74
77
|
path.split('/').map do |part|
|
|
75
|
-
part.split('_').map
|
|
78
|
+
inflections[part] || part.split('_').map do |word|
|
|
79
|
+
inflections[word] || (word[0] ? word[0].upcase + word[1..] : word)
|
|
80
|
+
end.join
|
|
76
81
|
end.join('::')
|
|
77
82
|
end
|
|
78
83
|
|
|
@@ -164,6 +169,12 @@ module ArchSpec
|
|
|
164
169
|
extend: :extends
|
|
165
170
|
}.freeze
|
|
166
171
|
|
|
172
|
+
ATTR_MESSAGES = {
|
|
173
|
+
attr_reader: %i[reader],
|
|
174
|
+
attr_writer: %i[writer],
|
|
175
|
+
attr_accessor: %i[reader writer]
|
|
176
|
+
}.freeze
|
|
177
|
+
|
|
167
178
|
def visit(graph, path, node, current_constant: nil, namespace: [])
|
|
168
179
|
return unless node
|
|
169
180
|
|
|
@@ -197,15 +208,25 @@ module ArchSpec
|
|
|
197
208
|
)
|
|
198
209
|
|
|
199
210
|
if node.superclass
|
|
200
|
-
superclass = constant_reference_name(node.superclass)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
superclass = constant_reference_name(node.superclass) if constant_node?(node.superclass)
|
|
212
|
+
|
|
213
|
+
if superclass
|
|
214
|
+
constant.superclass = superclass
|
|
215
|
+
graph.add_edge(
|
|
216
|
+
type: :inherits_from,
|
|
217
|
+
from_path: path,
|
|
218
|
+
from_constant: constant.name,
|
|
219
|
+
to: superclass,
|
|
220
|
+
location: SourceLocation.from_prism(path, node.superclass.location)
|
|
221
|
+
)
|
|
222
|
+
else
|
|
223
|
+
# Dynamic superclass (Struct.new, DelegateClass(...)): no
|
|
224
|
+
# inherits_from edge, but constants inside still count as
|
|
225
|
+
# references, and ancestry stays marked unresolved.
|
|
226
|
+
constant.superclass = node.superclass.slice
|
|
227
|
+
visit(graph, path, node.superclass, current_constant: constant.name,
|
|
228
|
+
namespace: constant.name.split('::'))
|
|
229
|
+
end
|
|
209
230
|
end
|
|
210
231
|
|
|
211
232
|
visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
|
|
@@ -277,6 +298,8 @@ module ArchSpec
|
|
|
277
298
|
)
|
|
278
299
|
end
|
|
279
300
|
|
|
301
|
+
record_generated_methods(graph, path, node, message, current_constant, location)
|
|
302
|
+
|
|
280
303
|
if (edge_type = MIXIN_MESSAGES[message])
|
|
281
304
|
constant_arguments(node).each do |constant_name|
|
|
282
305
|
if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
|
|
@@ -300,7 +323,8 @@ module ArchSpec
|
|
|
300
323
|
from_path: path,
|
|
301
324
|
from_constant: current_constant,
|
|
302
325
|
to: message,
|
|
303
|
-
location: location
|
|
326
|
+
location: location,
|
|
327
|
+
receiver: receiver_kind(node)
|
|
304
328
|
)
|
|
305
329
|
|
|
306
330
|
if DYNAMIC_MESSAGES.include?(message)
|
|
@@ -318,11 +342,14 @@ module ArchSpec
|
|
|
318
342
|
end
|
|
319
343
|
|
|
320
344
|
def add_constant_reference(graph, path, node, current_constant)
|
|
345
|
+
name = constant_reference_name(node)
|
|
346
|
+
return unless name
|
|
347
|
+
|
|
321
348
|
graph.add_edge(
|
|
322
349
|
type: :references_constant,
|
|
323
350
|
from_path: path,
|
|
324
351
|
from_constant: current_constant,
|
|
325
|
-
to:
|
|
352
|
+
to: name,
|
|
326
353
|
location: SourceLocation.from_prism(path, node.location)
|
|
327
354
|
)
|
|
328
355
|
end
|
|
@@ -349,27 +376,76 @@ module ArchSpec
|
|
|
349
376
|
end || []
|
|
350
377
|
end
|
|
351
378
|
|
|
379
|
+
# attr_* and unprefixed delegate calls define instance methods; without
|
|
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)
|
|
382
|
+
return unless current_constant && node.receiver.nil?
|
|
383
|
+
return unless ATTR_MESSAGES.key?(message) || message == :delegate
|
|
384
|
+
|
|
385
|
+
constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path }
|
|
386
|
+
return unless constant
|
|
387
|
+
|
|
388
|
+
names = symbol_arguments(node)
|
|
389
|
+
return if message == :delegate && keyword_argument?(node, :prefix)
|
|
390
|
+
|
|
391
|
+
names.each do |name|
|
|
392
|
+
kinds = ATTR_MESSAGES.fetch(message, %i[reader])
|
|
393
|
+
constant.add_instance_method(name, location: location) if kinds.include?(:reader)
|
|
394
|
+
constant.add_instance_method(:"#{name}=", location: location) if kinds.include?(:writer)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def symbol_arguments(node)
|
|
399
|
+
node.arguments&.arguments&.filter_map do |argument|
|
|
400
|
+
argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
|
|
401
|
+
end || []
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def keyword_argument?(node, name)
|
|
405
|
+
node.arguments&.arguments&.any? do |argument|
|
|
406
|
+
argument.is_a?(Prism::KeywordHashNode) && argument.elements.any? do |element|
|
|
407
|
+
element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
|
|
408
|
+
element.key.unescaped.to_sym == name
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def receiver_kind(node)
|
|
414
|
+
receiver = node.receiver
|
|
415
|
+
return :none if receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
416
|
+
return :constant if constant_node?(receiver)
|
|
417
|
+
|
|
418
|
+
:other
|
|
419
|
+
end
|
|
420
|
+
|
|
352
421
|
def instantiates_and_invokes(node)
|
|
353
422
|
receiver = node.receiver
|
|
354
423
|
return unless receiver.is_a?(Prism::CallNode) && receiver.message&.to_sym == :new
|
|
355
424
|
|
|
356
|
-
|
|
425
|
+
receiver_node = receiver.receiver
|
|
426
|
+
name = (constant_reference_name(receiver_node) if constant_node?(receiver_node)) || receiver_node&.slice
|
|
357
427
|
"#{name}##{node.message}"
|
|
358
428
|
end
|
|
359
429
|
|
|
430
|
+
# class Users::RolesController inside module Admin defines
|
|
431
|
+
# Admin::Users::RolesController, so compact paths join the namespace too.
|
|
360
432
|
def qualified_constant_name(node, namespace)
|
|
361
433
|
raw = constant_reference_name(node)
|
|
362
434
|
absolute = node.respond_to?(:full_name_parts) && node.full_name_parts.first == :""
|
|
363
435
|
|
|
364
|
-
if absolute ||
|
|
436
|
+
if absolute || namespace.empty?
|
|
365
437
|
raw
|
|
366
438
|
else
|
|
367
439
|
"#{namespace.join('::')}::#{raw}"
|
|
368
440
|
end
|
|
369
441
|
end
|
|
370
442
|
|
|
443
|
+
# nil when the path has dynamic parts (self.class::FOO): there is no
|
|
444
|
+
# static name to check against.
|
|
371
445
|
def constant_reference_name(node)
|
|
372
446
|
node.full_name.to_s.sub(/\A::/, '')
|
|
447
|
+
rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
|
|
448
|
+
nil
|
|
373
449
|
end
|
|
374
450
|
|
|
375
451
|
def constant_node?(node)
|
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
|
+
# Bundled architecture presets. Each applies a set of components and rules in
|
|
5
|
+
# one call, invoked from the DSL through
|
|
6
|
+
# ArchSpec::DSL::Context#architecture:
|
|
7
|
+
#
|
|
8
|
+
# architecture :rails
|
|
9
|
+
# architecture :layered, layers: { ... }
|
|
10
|
+
#
|
|
11
|
+
# Every preset accepts overrides for its directories, so you can keep the
|
|
12
|
+
# shape while pointing at your own paths. The presets are:
|
|
13
|
+
#
|
|
14
|
+
# +:rails+ (aliases +:rails_mvc+, +:rails_way+):: Conventional MVC. Keeps
|
|
15
|
+
# controller APIs out of models and services. Options: +components:+,
|
|
16
|
+
# +controller_api:+, +share_helpers:+.
|
|
17
|
+
# +:rails_strict+:: +:rails+ plus Zeitwerk name checks, a cycle check, and a
|
|
18
|
+
# concern independence check. Options add +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
|
+
# +controller_api:+, +share_helpers:+, +concerns:+.
|
|
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
|
+
# +adapters:+.
|
|
28
|
+
# +:clean+ (alias +:rails_clean+):: Clean architecture layers. Options:
|
|
29
|
+
# +frameworks:+, +interface_adapters:+, +use_cases:+, +entities:+.
|
|
30
|
+
# +:modular_monolith+ (alias +:bounded_contexts+):: Named packages with
|
|
31
|
+
# per-package allowlists and optional public APIs. Options: +components:+
|
|
32
|
+
# (required), +allow:+, +public:+.
|
|
33
|
+
# +:cqrs+ (alias +:rails_cqrs+):: Separates commands from queries and keeps
|
|
34
|
+
# writes out of queries. Options: +commands:+, +queries:+, +read_models:+,
|
|
35
|
+
# +mutating_methods:+.
|
|
36
|
+
# +:event_driven+ (alias +:rails_event_driven+):: Events, publishers, and
|
|
37
|
+
# subscribers. Options: +events:+, +publishers:+, +subscribers:+.
|
|
38
|
+
#
|
|
39
|
+
# See the guides at https://archspecrb.dev/architectures/ for each in depth.
|
|
4
40
|
module Architectures
|
|
5
41
|
extend self
|
|
6
42
|
|
|
@@ -54,6 +90,8 @@ module ArchSpec
|
|
|
54
90
|
view_components: ['app/components/**/*.rb', 'use helpers and ERB partials']
|
|
55
91
|
}.freeze
|
|
56
92
|
|
|
93
|
+
DEFAULT_CONCERNS = 'app/**/concerns/**/*.rb'
|
|
94
|
+
|
|
57
95
|
CONTROLLER_METHODS = %i[render redirect_to params session cookies flash].freeze
|
|
58
96
|
MUTATING_METHODS = %i[
|
|
59
97
|
create create!
|
|
@@ -65,17 +103,34 @@ module ArchSpec
|
|
|
65
103
|
upsert upsert!
|
|
66
104
|
].freeze
|
|
67
105
|
|
|
106
|
+
# Applies the named preset to +dsl+, forwarding +options+ to it. Raises
|
|
107
|
+
# ArchSpec::Error for an unknown name. Called by
|
|
108
|
+
# ArchSpec::DSL::Context#architecture, so you rarely call it directly.
|
|
68
109
|
def apply(name, dsl, **options)
|
|
69
110
|
case name.to_sym
|
|
70
111
|
when :rails, :rails_mvc, :rails_way
|
|
71
|
-
rails_mvc(
|
|
112
|
+
rails_mvc(
|
|
113
|
+
dsl,
|
|
114
|
+
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
115
|
+
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
116
|
+
share_helpers: options.fetch(:share_helpers, false)
|
|
117
|
+
)
|
|
72
118
|
when :rails_strict
|
|
73
|
-
rails_strict(
|
|
119
|
+
rails_strict(
|
|
120
|
+
dsl,
|
|
121
|
+
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
122
|
+
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
123
|
+
share_helpers: options.fetch(:share_helpers, false),
|
|
124
|
+
concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
|
|
125
|
+
)
|
|
74
126
|
when :vanilla_rails
|
|
75
127
|
vanilla_rails(
|
|
76
128
|
dsl,
|
|
77
129
|
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
78
|
-
empty: options.fetch(:empty, VANILLA_RAILS_EMPTY)
|
|
130
|
+
empty: options.fetch(:empty, VANILLA_RAILS_EMPTY),
|
|
131
|
+
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
132
|
+
share_helpers: options.fetch(:share_helpers, false),
|
|
133
|
+
concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
|
|
79
134
|
)
|
|
80
135
|
when :layered, :rails_layered
|
|
81
136
|
layered(dsl, layers: options.fetch(:layers, DEFAULT_LAYERED))
|
|
@@ -84,7 +139,12 @@ module ArchSpec
|
|
|
84
139
|
when :clean, :rails_clean
|
|
85
140
|
clean(dsl, **with_defaults(DEFAULT_CLEAN, options))
|
|
86
141
|
when :modular_monolith, :bounded_contexts
|
|
87
|
-
modular_monolith(
|
|
142
|
+
modular_monolith(
|
|
143
|
+
dsl,
|
|
144
|
+
components: options.fetch(:components),
|
|
145
|
+
allow: options.fetch(:allow, {}),
|
|
146
|
+
public: options.fetch(:public, {})
|
|
147
|
+
)
|
|
88
148
|
when :cqrs, :rails_cqrs
|
|
89
149
|
cqrs(dsl, **with_defaults(DEFAULT_CQRS, options))
|
|
90
150
|
when :event_driven, :rails_event_driven
|
|
@@ -94,30 +154,38 @@ module ArchSpec
|
|
|
94
154
|
end
|
|
95
155
|
end
|
|
96
156
|
|
|
97
|
-
def rails_mvc(dsl, components:)
|
|
157
|
+
def rails_mvc(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false)
|
|
98
158
|
components = normalize_map(components)
|
|
99
159
|
define_components(dsl, components)
|
|
100
160
|
|
|
161
|
+
forbidden = share_helpers ? %i[controllers] : %i[controllers helpers]
|
|
101
162
|
proxy_for(dsl, :controllers).can_use(*components.keys & %i[models services helpers mailers jobs])
|
|
102
|
-
proxy_for(dsl, :models).cannot_use(*components.keys &
|
|
103
|
-
proxy_for(dsl, :services).cannot_use(*components.keys &
|
|
104
|
-
|
|
105
|
-
|
|
163
|
+
proxy_for(dsl, :models).cannot_use(*components.keys & forbidden)
|
|
164
|
+
proxy_for(dsl, :services).cannot_use(*components.keys & forbidden)
|
|
165
|
+
|
|
166
|
+
return if controller_api.empty?
|
|
167
|
+
|
|
168
|
+
proxy_for(dsl, :models).cannot_call(*controller_api, receiver: :none)
|
|
169
|
+
proxy_for(dsl, :services).cannot_call(*controller_api, receiver: :none)
|
|
106
170
|
end
|
|
107
171
|
|
|
108
|
-
def rails_strict(dsl, components:)
|
|
172
|
+
def rails_strict(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false, concerns: DEFAULT_CONCERNS)
|
|
109
173
|
components = normalize_map(components)
|
|
110
|
-
rails_mvc(dsl, components: components)
|
|
174
|
+
rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
111
175
|
dsl.verify_zeitwerk_names!
|
|
112
176
|
dsl.no_cycles!(among: components.keys)
|
|
177
|
+
independent_concerns(dsl, concerns)
|
|
113
178
|
end
|
|
114
179
|
|
|
115
|
-
def vanilla_rails(dsl, components:, empty:
|
|
116
|
-
|
|
180
|
+
def vanilla_rails(dsl, components:, empty:, controller_api: CONTROLLER_METHODS, share_helpers: false,
|
|
181
|
+
concerns: DEFAULT_CONCERNS)
|
|
182
|
+
rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
117
183
|
|
|
118
184
|
empty.each do |name, (pattern, reason)|
|
|
119
185
|
dsl.component(name, in: pattern).must_be_empty(because: reason)
|
|
120
186
|
end
|
|
187
|
+
|
|
188
|
+
independent_concerns(dsl, concerns)
|
|
121
189
|
end
|
|
122
190
|
|
|
123
191
|
def layered(dsl, layers:)
|
|
@@ -161,13 +229,16 @@ module ArchSpec
|
|
|
161
229
|
)
|
|
162
230
|
end
|
|
163
231
|
|
|
164
|
-
def modular_monolith(dsl, components:, allow: {})
|
|
232
|
+
def modular_monolith(dsl, components:, allow: {}, public: {})
|
|
165
233
|
components = normalize_map(components)
|
|
166
234
|
define_components(dsl, components)
|
|
167
235
|
|
|
168
236
|
components.each_key do |name|
|
|
169
237
|
allowed = Array(allow[name] || allow[name.to_s])
|
|
170
238
|
proxy_for(dsl, name).can_use(*allowed)
|
|
239
|
+
|
|
240
|
+
patterns = Array(public[name] || public[name.to_s])
|
|
241
|
+
proxy_for(dsl, name).public_api(*patterns) if patterns.any?
|
|
171
242
|
end
|
|
172
243
|
|
|
173
244
|
dsl.no_cycles!(among: components.keys)
|
|
@@ -226,5 +297,11 @@ module ArchSpec
|
|
|
226
297
|
def proxy_for(dsl, name)
|
|
227
298
|
DSL::ComponentProxy.new(dsl, name)
|
|
228
299
|
end
|
|
300
|
+
|
|
301
|
+
def independent_concerns(dsl, pattern)
|
|
302
|
+
return unless pattern
|
|
303
|
+
|
|
304
|
+
dsl.component(:concerns, in: pattern).cannot_reference_includers
|
|
305
|
+
end
|
|
229
306
|
end
|
|
230
307
|
end
|
data/lib/archspec/cli.rb
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
require 'optparse'
|
|
4
4
|
|
|
5
5
|
module ArchSpec
|
|
6
|
+
# The <tt>archspec</tt> command line. Backs the +exe/archspec+ executable and
|
|
7
|
+
# dispatches the +init+, +check+, +explain+, and +version+ subcommands.
|
|
8
|
+
#
|
|
9
|
+
# archspec init
|
|
10
|
+
# archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
|
|
11
|
+
# archspec explain PATH_OR_CONSTANT
|
|
12
|
+
#
|
|
13
|
+
# #run returns the process exit status: 0 when clean, 1 when violations are
|
|
14
|
+
# found.
|
|
6
15
|
module CLI
|
|
7
16
|
extend self
|
|
8
17
|
|
|
@@ -52,30 +61,33 @@ module ArchSpec
|
|
|
52
61
|
options = {
|
|
53
62
|
config: CONFIG_FILE,
|
|
54
63
|
format: 'text',
|
|
55
|
-
|
|
64
|
+
update_todo: false
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
parser = OptionParser.new do |opts|
|
|
59
68
|
opts.on('--config PATH') { |value| options[:config] = value }
|
|
60
69
|
opts.on('--format FORMAT') { |value| options[:format] = value }
|
|
61
|
-
opts.on('--update-
|
|
70
|
+
opts.on('--update-todo') { options[:update_todo] = true }
|
|
62
71
|
end
|
|
63
72
|
parser.parse!(argv)
|
|
64
73
|
|
|
74
|
+
raise Error, 'Cannot combine --update-todo with path arguments.' if options[:update_todo] && argv.any?
|
|
75
|
+
|
|
65
76
|
definition, root = load_definition(options[:config])
|
|
66
77
|
graph = Analyzer.analyze(definition, root: root)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
diagnostics = Evaluator.evaluate(definition, graph,
|
|
78
|
+
todo_path = todo_path_for(definition, root)
|
|
79
|
+
todo = options[:update_todo] ? Todo.empty(root: root) : Todo.load(todo_path, root: root)
|
|
80
|
+
diagnostics = Evaluator.evaluate(definition, graph, todo: todo)
|
|
81
|
+
diagnostics = scope_to_paths(diagnostics, argv, root)
|
|
70
82
|
|
|
71
|
-
if options[:
|
|
72
|
-
unless
|
|
83
|
+
if options[:update_todo]
|
|
84
|
+
unless todo_path
|
|
73
85
|
raise Error,
|
|
74
|
-
"No
|
|
86
|
+
"No todo configured. Add `todo \"archspec_todo.yml\"` to #{options[:config]}."
|
|
75
87
|
end
|
|
76
88
|
|
|
77
|
-
|
|
78
|
-
output.puts "Updated #{Pathname(
|
|
89
|
+
Todo.write(todo_path, diagnostics, root: root)
|
|
90
|
+
output.puts "Updated #{Pathname(todo_path).relative_path_from(Pathname(root))} with #{diagnostics.size} violations."
|
|
79
91
|
return 0
|
|
80
92
|
end
|
|
81
93
|
|
|
@@ -104,18 +116,32 @@ module ArchSpec
|
|
|
104
116
|
|
|
105
117
|
ArchSpec.last_definition = nil
|
|
106
118
|
absolute_config = File.expand_path(config_path)
|
|
119
|
+
config_dir = File.dirname(absolute_config)
|
|
107
120
|
definition = Definition.new
|
|
121
|
+
definition.base_dir = config_dir
|
|
108
122
|
definition.extend(DSL::Context)
|
|
109
123
|
definition.instance_eval(File.read(absolute_config), absolute_config)
|
|
110
124
|
definition = ArchSpec.last_definition || definition
|
|
125
|
+
definition.base_dir ||= config_dir
|
|
126
|
+
|
|
127
|
+
[definition, definition.absolute_root(config_dir)]
|
|
128
|
+
end
|
|
111
129
|
|
|
112
|
-
|
|
130
|
+
def scope_to_paths(diagnostics, paths, root)
|
|
131
|
+
return diagnostics if paths.empty?
|
|
132
|
+
|
|
133
|
+
expanded = paths.map { |path| File.expand_path(path, root) }
|
|
134
|
+
diagnostics.select do |diagnostic|
|
|
135
|
+
expanded.any? do |path|
|
|
136
|
+
diagnostic.location.path == path || diagnostic.location.path.start_with?("#{path}/")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
113
139
|
end
|
|
114
140
|
|
|
115
|
-
def
|
|
116
|
-
return unless definition.
|
|
141
|
+
def todo_path_for(definition, root)
|
|
142
|
+
return unless definition.todo_path
|
|
117
143
|
|
|
118
|
-
File.expand_path(definition.
|
|
144
|
+
File.expand_path(definition.todo_path, root)
|
|
119
145
|
end
|
|
120
146
|
|
|
121
147
|
def formatter_for(name)
|
|
@@ -205,7 +231,7 @@ module ArchSpec
|
|
|
205
231
|
<<~TEXT
|
|
206
232
|
Usage:
|
|
207
233
|
archspec init [PATH] [--force]
|
|
208
|
-
archspec check [--config PATH] [--format text|json] [--update-
|
|
234
|
+
archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
|
|
209
235
|
archspec explain PATH_OR_CONSTANT [--config PATH]
|
|
210
236
|
archspec version
|
|
211
237
|
TEXT
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
|
+
# How a component selects its members: by file glob, by namespace, or by
|
|
5
|
+
# explicit constant name. Created by ArchSpec::DSL::Context#component. The
|
|
6
|
+
# analyzer uses it to assign files and constants to the component.
|
|
4
7
|
class ComponentSpec
|
|
5
8
|
attr_reader :name, :file_patterns, :namespaces, :constants
|
|
6
9
|
|
data/lib/archspec/definition.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
|
+
# The result of evaluating an +Archspec.rb+ file: the project settings,
|
|
5
|
+
# declared components, and rules. ArchSpec::DSL::Context is mixed into an
|
|
6
|
+
# instance to provide the DSL, and the analyzer and evaluator read it to run
|
|
7
|
+
# the checks. Build one with ArchSpec.define.
|
|
4
8
|
class Definition
|
|
5
9
|
DEFAULT_SOURCE_PATTERNS = [
|
|
6
10
|
'app/**/*.rb',
|
|
@@ -17,17 +21,23 @@ module ArchSpec
|
|
|
17
21
|
'vendor/**/*'
|
|
18
22
|
].freeze
|
|
19
23
|
|
|
20
|
-
attr_accessor :name, :root_path, :
|
|
21
|
-
attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
|
|
24
|
+
attr_accessor :name, :root_path, :todo_path, :base_dir
|
|
25
|
+
attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules, :inflections
|
|
22
26
|
|
|
23
27
|
def initialize(name = nil)
|
|
24
28
|
@name = name
|
|
25
29
|
@root_path = '.'
|
|
26
|
-
@
|
|
30
|
+
@todo_path = nil
|
|
31
|
+
@base_dir = nil
|
|
27
32
|
@source_patterns = []
|
|
28
33
|
@ignore_patterns = DEFAULT_IGNORE_PATTERNS.dup
|
|
29
34
|
@component_specs = {}
|
|
30
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))
|
|
31
41
|
end
|
|
32
42
|
|
|
33
43
|
def add_source_patterns(patterns)
|
|
@@ -54,8 +64,11 @@ module ArchSpec
|
|
|
54
64
|
rules << rule
|
|
55
65
|
end
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
# The directory file patterns resolve against: root_path expanded from the
|
|
68
|
+
# directory the Archspec.rb was loaded from (base_dir), or the working
|
|
69
|
+
# directory when built without a file.
|
|
70
|
+
def absolute_root(base = base_dir || Dir.pwd)
|
|
71
|
+
File.expand_path(root_path, base)
|
|
59
72
|
end
|
|
60
73
|
|
|
61
74
|
def analysis_patterns
|
data/lib/archspec/diagnostic.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
require 'digest'
|
|
4
4
|
|
|
5
5
|
module ArchSpec
|
|
6
|
+
# One reported violation: the rule id, a message, the source location, the
|
|
7
|
+
# evidence ArchSpec found, and a confidence. Formatters and the todo file read
|
|
8
|
+
# these. Its #fingerprint is the stable id used to match todo entries and
|
|
9
|
+
# suppress specific findings.
|
|
6
10
|
class Diagnostic
|
|
7
11
|
attr_reader :rule, :message, :location, :evidence, :confidence
|
|
8
12
|
|
|
@@ -14,11 +18,13 @@ module ArchSpec
|
|
|
14
18
|
@confidence = confidence
|
|
15
19
|
end
|
|
16
20
|
|
|
21
|
+
# Line numbers stay out of the fingerprint so todo entries survive edits
|
|
22
|
+
# that only shift code around.
|
|
17
23
|
def fingerprint(root: nil)
|
|
18
24
|
path = root ? location.relative_path(root) : location.path
|
|
19
25
|
|
|
20
26
|
Digest::SHA256.hexdigest(
|
|
21
|
-
[rule, message, path,
|
|
27
|
+
[rule, message, path, evidence].join("\0")
|
|
22
28
|
)[0, 24]
|
|
23
29
|
end
|
|
24
30
|
|