archspec 0.5.0 → 1.0.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 +26 -8
- data/lib/archspec/analyzer.rb +97 -53
- data/lib/archspec/architectures.rb +88 -84
- data/lib/archspec/cli.rb +114 -34
- data/lib/archspec/diagnostic.rb +2 -0
- data/lib/archspec/dsl.rb +16 -0
- data/lib/archspec/error.rb +7 -0
- data/lib/archspec/evaluator.rb +6 -8
- data/lib/archspec/formatters/explanation.rb +68 -38
- data/lib/archspec/formatters/style.rb +39 -0
- data/lib/archspec/formatters/text.rb +92 -8
- data/lib/archspec/model.rb +119 -36
- data/lib/archspec/rules/component_rules.rb +1 -1
- data/lib/archspec/rules/concern_rules.rb +3 -3
- data/lib/archspec/rules/cycle_rule.rb +2 -2
- data/lib/archspec/rules/dependency_rules.rb +10 -18
- data/lib/archspec/rules/naming_rules.rb +31 -3
- data/lib/archspec/rules/privacy_rule.rb +3 -3
- data/lib/archspec/rules/protocol_rules.rb +9 -6
- data/lib/archspec/source_location.rb +8 -2
- data/lib/archspec/todo.rb +17 -2
- data/lib/archspec/value_object.rb +4 -0
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +8 -13
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b50ec4f86cfdf9e4f95b1cf4a28c4dec212d711781675dedd6648f13aed7c1f
|
|
4
|
+
data.tar.gz: 9303aefb00b161004cc515b0d1b2ad03b3e60f396ef34b9dc86be4e7a1825650
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56bd81e5ee880404a80c7f29d498c58903bd8d087c5e8deb80af060d6d169c3b40e471eb09758500d6522516d83090b435b45ab243f780978539840b9548681e
|
|
7
|
+
data.tar.gz: a3dc2d41b6b97edd3ddacb8c84632c0b8ae81471c9132ec02ddbf66ce68d8dd916d068aeb366a8bed99f996ed6f81cda8a3a48b502459b0d25afc525d81df95d
|
data/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# ArchSpec
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Static analysis for your architecture. A linter for Ruby and Rails boundaries.
|
|
4
4
|
|
|
5
5
|
ArchSpec turns your application's architecture into executable checks. Declare
|
|
6
6
|
your components, dependencies, and boundaries in one file, then check every
|
|
7
|
-
change in CI, whether a person or a coding agent wrote it. It
|
|
8
|
-
with Prism
|
|
7
|
+
change in CI, whether a person or a coding agent wrote it. It is plain static
|
|
8
|
+
analysis: it reads Ruby source with Prism, never boots the app, and no AI is
|
|
9
|
+
involved in checking your code.
|
|
9
10
|
|
|
10
11
|
It maps conventional Rails files to constants and checks the structural rules
|
|
11
12
|
you write down: components, layers, constant references, inheritance, mixins,
|
|
@@ -164,7 +165,18 @@ bundle exec archspec explain app/models/user.rb
|
|
|
164
165
|
```
|
|
165
166
|
|
|
166
167
|
`explain` shows why a file or constant belongs to a component and which outgoing
|
|
167
|
-
facts ArchSpec found
|
|
168
|
+
facts ArchSpec found:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
app/models/user.rb
|
|
172
|
+
|
|
173
|
+
defined constants: User
|
|
174
|
+
components:
|
|
175
|
+
models: matched file pattern app/models/**/*.rb
|
|
176
|
+
outgoing facts:
|
|
177
|
+
1:14 │ inherits from ApplicationRecord
|
|
178
|
+
2:3 │ references UsersController
|
|
179
|
+
```
|
|
168
180
|
|
|
169
181
|
## Checking AI-Written Code
|
|
170
182
|
|
|
@@ -182,10 +194,16 @@ Passing paths still analyzes the project so dependencies resolve, but reports on
|
|
|
182
194
|
If it fails, read the evidence before changing the spec:
|
|
183
195
|
|
|
184
196
|
```text
|
|
185
|
-
[dependencies.forbid]
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
197
|
+
[error] models must not depend on controllers [dependencies.forbid]
|
|
198
|
+
|
|
199
|
+
app/models/user.rb:2:3
|
|
200
|
+
|
|
201
|
+
1 │ class User < ApplicationRecord
|
|
202
|
+
→ 2 │ UsersController
|
|
203
|
+
│ ^~~~~~~~~~~~~~~
|
|
204
|
+
3 │ end
|
|
205
|
+
|
|
206
|
+
note: User references UsersController
|
|
189
207
|
```
|
|
190
208
|
|
|
191
209
|
Most failures should be fixed in the generated code. Update the spec only when
|
data/lib/archspec/analyzer.rb
CHANGED
|
@@ -81,7 +81,9 @@ module ArchSpec
|
|
|
81
81
|
when 'next-line'
|
|
82
82
|
suppressions << Suppression.new(rule, line + 1, line + 1, reason)
|
|
83
83
|
else
|
|
84
|
-
|
|
84
|
+
# The block form covers its own line too, so a trailing
|
|
85
|
+
# `# archspec:disable RULE` works like RuboCop's.
|
|
86
|
+
active[rule] << [line, reason]
|
|
85
87
|
end
|
|
86
88
|
elsif (match = text.match(ENABLE_PATTERN))
|
|
87
89
|
rule = normalize_rule(match[1])
|
|
@@ -141,43 +143,49 @@ module ArchSpec
|
|
|
141
143
|
attr_accessor: %i[reader writer]
|
|
142
144
|
}.freeze
|
|
143
145
|
|
|
144
|
-
def visit(graph, path, node, current_constant: nil, namespace: [],
|
|
146
|
+
def visit(graph, path, node, current_constant: nil, namespace: [], nesting: [], visibility: :public,
|
|
147
|
+
default_scope: :instance)
|
|
145
148
|
return unless node
|
|
146
149
|
|
|
147
150
|
case node
|
|
148
151
|
when Prism::ProgramNode, Prism::StatementsNode
|
|
149
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
152
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
|
|
150
153
|
visibility: visibility, default_scope: default_scope)
|
|
151
154
|
when Prism::ClassNode
|
|
152
|
-
visit_class(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
155
|
+
visit_class(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
|
|
153
156
|
when Prism::ModuleNode
|
|
154
|
-
visit_module(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
157
|
+
visit_module(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
|
|
155
158
|
when Prism::SingletonClassNode
|
|
156
159
|
visit_singleton_class(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
157
|
-
|
|
160
|
+
nesting: nesting, visibility: visibility,
|
|
161
|
+
default_scope: default_scope)
|
|
158
162
|
when Prism::DefNode
|
|
159
163
|
visit_def(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
160
|
-
visibility: visibility, default_scope: default_scope)
|
|
164
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
161
165
|
when Prism::CallNode
|
|
162
166
|
visit_call(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
163
|
-
visibility: visibility, default_scope: default_scope)
|
|
167
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
164
168
|
when Prism::ConstantPathNode, Prism::ConstantReadNode
|
|
165
|
-
add_constant_reference(graph, path, node, current_constant)
|
|
169
|
+
add_constant_reference(graph, path, node, current_constant, nesting)
|
|
166
170
|
else
|
|
167
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
171
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
|
|
168
172
|
visibility: visibility, default_scope: default_scope)
|
|
169
173
|
end
|
|
170
174
|
end
|
|
171
175
|
|
|
172
176
|
private
|
|
173
177
|
|
|
174
|
-
def visit_class(graph, path, node, current_constant:, namespace:)
|
|
178
|
+
def visit_class(graph, path, node, current_constant:, namespace:, nesting:)
|
|
175
179
|
name = qualified_constant_name(node.constant_path, namespace)
|
|
180
|
+
return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
181
|
+
nesting: nesting) unless name
|
|
182
|
+
|
|
176
183
|
constant = graph.add_constant(
|
|
177
184
|
name: name,
|
|
178
185
|
kind: :class,
|
|
179
186
|
path: path,
|
|
180
|
-
location: SourceLocation.from_prism(path, node.location)
|
|
187
|
+
location: SourceLocation.from_prism(path, node.location),
|
|
188
|
+
nesting: nesting
|
|
181
189
|
)
|
|
182
190
|
|
|
183
191
|
if node.superclass
|
|
@@ -190,31 +198,38 @@ module ArchSpec
|
|
|
190
198
|
from_path: path,
|
|
191
199
|
from_constant: constant.name,
|
|
192
200
|
to: superclass,
|
|
193
|
-
location: SourceLocation.from_prism(path, node.superclass.location)
|
|
201
|
+
location: SourceLocation.from_prism(path, node.superclass.location),
|
|
202
|
+
lexical_nesting: nesting
|
|
194
203
|
)
|
|
195
204
|
else
|
|
196
205
|
# Dynamic superclass (Struct.new, DelegateClass(...)): no
|
|
197
206
|
# inherits_from edge, but constants inside still count as
|
|
198
207
|
# references, and ancestry stays marked unresolved.
|
|
199
208
|
constant.superclass = node.superclass.slice
|
|
200
|
-
visit(graph, path, node.superclass, current_constant: constant.name,
|
|
201
|
-
|
|
209
|
+
visit(graph, path, node.superclass, current_constant: constant.name, namespace: namespace,
|
|
210
|
+
nesting: nesting)
|
|
202
211
|
end
|
|
203
212
|
end
|
|
204
213
|
|
|
205
|
-
visit_constant_body(graph, path, constant, node.body, constant.name.split('::')
|
|
214
|
+
visit_constant_body(graph, path, constant, node.body, constant.name.split('::'),
|
|
215
|
+
nesting: [constant.name] + nesting)
|
|
206
216
|
end
|
|
207
217
|
|
|
208
|
-
def visit_module(graph, path, node, current_constant:, namespace:)
|
|
218
|
+
def visit_module(graph, path, node, current_constant:, namespace:, nesting:)
|
|
209
219
|
name = qualified_constant_name(node.constant_path, namespace)
|
|
220
|
+
return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
221
|
+
nesting: nesting) unless name
|
|
222
|
+
|
|
210
223
|
constant = graph.add_constant(
|
|
211
224
|
name: name,
|
|
212
225
|
kind: :module,
|
|
213
226
|
path: path,
|
|
214
|
-
location: SourceLocation.from_prism(path, node.location)
|
|
227
|
+
location: SourceLocation.from_prism(path, node.location),
|
|
228
|
+
nesting: nesting
|
|
215
229
|
)
|
|
216
230
|
|
|
217
|
-
visit_constant_body(graph, path, constant, node.body, constant.name.split('::')
|
|
231
|
+
visit_constant_body(graph, path, constant, node.body, constant.name.split('::'),
|
|
232
|
+
nesting: [constant.name] + nesting)
|
|
218
233
|
end
|
|
219
234
|
|
|
220
235
|
# A +class << self+ (or +class << SomeConstant+) block defines methods on a
|
|
@@ -222,15 +237,15 @@ module ArchSpec
|
|
|
222
237
|
# applies to them. Walk the body with +default_scope: :class+ against the
|
|
223
238
|
# target constant. An unknown target (+class << variable+) still has its
|
|
224
239
|
# body visited for edges, but no methods are attributed.
|
|
225
|
-
def visit_singleton_class(graph, path, node, current_constant:, namespace:, visibility:, default_scope:)
|
|
240
|
+
def visit_singleton_class(graph, path, node, current_constant:, namespace:, nesting:, visibility:, default_scope:)
|
|
226
241
|
target = singleton_target(node.expression, current_constant)
|
|
227
242
|
constant = target && graph.constants_named(target).find { |candidate| candidate.path == path }
|
|
228
243
|
|
|
229
244
|
if constant
|
|
230
|
-
visit_constant_body(graph, path, constant, node.body, namespace, default_scope: :class)
|
|
245
|
+
visit_constant_body(graph, path, constant, node.body, namespace, nesting: nesting, default_scope: :class)
|
|
231
246
|
else
|
|
232
|
-
visit_children(graph, path, node, current_constant:
|
|
233
|
-
visibility: visibility, default_scope:
|
|
247
|
+
visit_children(graph, path, node, current_constant: nil, namespace: namespace,
|
|
248
|
+
nesting: nesting, visibility: visibility, default_scope: :class)
|
|
234
249
|
end
|
|
235
250
|
end
|
|
236
251
|
|
|
@@ -246,11 +261,11 @@ module ArchSpec
|
|
|
246
261
|
# so bare +def+s there are class methods. Every statement is still handed to
|
|
247
262
|
# +visit+, so all other facts (calls, references, mixins) are recorded
|
|
248
263
|
# exactly as before.
|
|
249
|
-
def visit_constant_body(graph, path, constant, body, namespace, default_scope: :instance)
|
|
264
|
+
def visit_constant_body(graph, path, constant, body, namespace, nesting:, default_scope: :instance)
|
|
250
265
|
return unless body
|
|
251
266
|
|
|
252
267
|
unless body.is_a?(Prism::StatementsNode)
|
|
253
|
-
return visit(graph, path, body, current_constant: constant.name, namespace: namespace,
|
|
268
|
+
return visit(graph, path, body, current_constant: constant.name, namespace: namespace, nesting: nesting,
|
|
254
269
|
default_scope: default_scope)
|
|
255
270
|
end
|
|
256
271
|
|
|
@@ -258,16 +273,18 @@ module ArchSpec
|
|
|
258
273
|
body.body.each do |statement|
|
|
259
274
|
if (modifier = visibility_modifier(statement))
|
|
260
275
|
visibility = apply_visibility_modifier(graph, path, constant, statement, namespace, visibility, modifier,
|
|
261
|
-
default_scope)
|
|
276
|
+
default_scope, nesting)
|
|
262
277
|
else
|
|
263
278
|
visit(graph, path, statement, current_constant: constant.name, namespace: namespace,
|
|
264
|
-
visibility: visibility, default_scope: default_scope)
|
|
279
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
265
280
|
end
|
|
266
281
|
end
|
|
267
282
|
end
|
|
268
283
|
|
|
269
|
-
def visit_def(graph, path, node, current_constant:, namespace:, visibility: :public,
|
|
270
|
-
|
|
284
|
+
def visit_def(graph, path, node, current_constant:, namespace:, nesting:, visibility: :public,
|
|
285
|
+
default_scope: :instance)
|
|
286
|
+
owner = method_definition_owner(graph, node, current_constant, nesting)
|
|
287
|
+
if owner && (constant = graph.constants_named(owner).find do |candidate|
|
|
271
288
|
candidate.path == path
|
|
272
289
|
end)
|
|
273
290
|
location = SourceLocation.from_prism(path, node.location)
|
|
@@ -289,13 +306,24 @@ module ArchSpec
|
|
|
289
306
|
)
|
|
290
307
|
end
|
|
291
308
|
|
|
292
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
309
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def method_definition_owner(graph, node, current_constant, nesting)
|
|
313
|
+
return current_constant unless node.receiver
|
|
314
|
+
return current_constant if node.receiver.is_a?(Prism::SelfNode)
|
|
315
|
+
return unless constant_node?(node.receiver)
|
|
316
|
+
|
|
317
|
+
name = constant_reference_name(node.receiver)
|
|
318
|
+
graph.resolve_constant_reference(name, current_constant, lexical_nesting: nesting) if name
|
|
293
319
|
end
|
|
294
320
|
|
|
295
|
-
def visit_call(graph, path, node, current_constant:, namespace:, visibility: :public,
|
|
321
|
+
def visit_call(graph, path, node, current_constant:, namespace:, nesting:, visibility: :public,
|
|
322
|
+
default_scope: :instance)
|
|
296
323
|
unless node.message
|
|
297
324
|
return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
298
|
-
|
|
325
|
+
nesting: nesting, visibility: visibility,
|
|
326
|
+
default_scope: default_scope)
|
|
299
327
|
end
|
|
300
328
|
|
|
301
329
|
message = node.message.to_sym
|
|
@@ -336,7 +364,8 @@ module ArchSpec
|
|
|
336
364
|
from_path: path,
|
|
337
365
|
from_constant: current_constant,
|
|
338
366
|
to: constant_name,
|
|
339
|
-
location: location
|
|
367
|
+
location: location,
|
|
368
|
+
lexical_nesting: nesting
|
|
340
369
|
)
|
|
341
370
|
end
|
|
342
371
|
end
|
|
@@ -361,11 +390,11 @@ module ArchSpec
|
|
|
361
390
|
)
|
|
362
391
|
end
|
|
363
392
|
|
|
364
|
-
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
|
|
393
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace, nesting: nesting,
|
|
365
394
|
visibility: visibility, default_scope: default_scope)
|
|
366
395
|
end
|
|
367
396
|
|
|
368
|
-
def add_constant_reference(graph, path, node, current_constant)
|
|
397
|
+
def add_constant_reference(graph, path, node, current_constant, nesting)
|
|
369
398
|
name = constant_reference_name(node)
|
|
370
399
|
return unless name
|
|
371
400
|
|
|
@@ -374,14 +403,16 @@ module ArchSpec
|
|
|
374
403
|
from_path: path,
|
|
375
404
|
from_constant: current_constant,
|
|
376
405
|
to: name,
|
|
377
|
-
location: SourceLocation.from_prism(path, node.location)
|
|
406
|
+
location: SourceLocation.from_prism(path, node.location),
|
|
407
|
+
lexical_nesting: nesting
|
|
378
408
|
)
|
|
379
409
|
end
|
|
380
410
|
|
|
381
|
-
def visit_children(graph, path, node, current_constant:, namespace:, visibility: :public,
|
|
411
|
+
def visit_children(graph, path, node, current_constant:, namespace:, nesting:, visibility: :public,
|
|
412
|
+
default_scope: :instance)
|
|
382
413
|
node.child_nodes.compact.each do |child|
|
|
383
414
|
visit(graph, path, child, current_constant: current_constant, namespace: namespace,
|
|
384
|
-
visibility: visibility, default_scope: default_scope)
|
|
415
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
385
416
|
end
|
|
386
417
|
end
|
|
387
418
|
|
|
@@ -410,7 +441,7 @@ module ArchSpec
|
|
|
410
441
|
# statements that follow it. A bare +private+ changes that default; the
|
|
411
442
|
# inline (+private def foo+) and symbol-list (+private :foo+) forms mark
|
|
412
443
|
# only the methods they name and leave the default unchanged.
|
|
413
|
-
def apply_visibility_modifier(graph, path, constant, node, namespace, current, spec, default_scope)
|
|
444
|
+
def apply_visibility_modifier(graph, path, constant, node, namespace, current, spec, default_scope, nesting)
|
|
414
445
|
visibility, forced_scope = spec
|
|
415
446
|
scope = forced_scope || default_scope
|
|
416
447
|
arguments = node.arguments&.arguments || []
|
|
@@ -419,20 +450,21 @@ module ArchSpec
|
|
|
419
450
|
|
|
420
451
|
if definitions.any?
|
|
421
452
|
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
422
|
-
visibility: visibility, default_scope: default_scope)
|
|
453
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
423
454
|
current
|
|
424
455
|
elsif names.any?
|
|
425
456
|
names.each { |name| constant.set_visibility(name.unescaped.to_sym, scope, visibility) }
|
|
426
457
|
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
427
|
-
visibility: current, default_scope: default_scope)
|
|
458
|
+
nesting: nesting, visibility: current, default_scope: default_scope)
|
|
428
459
|
current
|
|
429
|
-
elsif forced_scope.nil?
|
|
460
|
+
elsif arguments.empty? && forced_scope.nil?
|
|
430
461
|
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
431
|
-
visibility: visibility, default_scope: default_scope)
|
|
462
|
+
nesting: nesting, visibility: visibility, default_scope: default_scope)
|
|
432
463
|
visibility
|
|
433
464
|
else
|
|
434
465
|
visit(graph, path, node, current_constant: constant.name, namespace: namespace,
|
|
435
|
-
|
|
466
|
+
nesting: nesting, visibility: forced_scope.nil? ? visibility : current,
|
|
467
|
+
default_scope: default_scope)
|
|
436
468
|
current
|
|
437
469
|
end
|
|
438
470
|
end
|
|
@@ -461,10 +493,9 @@ module ArchSpec
|
|
|
461
493
|
|
|
462
494
|
constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path }
|
|
463
495
|
return unless constant
|
|
496
|
+
return if message == :delegate && truthy_keyword_argument?(node, :prefix)
|
|
464
497
|
|
|
465
498
|
names = generated_method_names(constant, node, message)
|
|
466
|
-
return if message == :delegate && keyword_argument?(node, :prefix)
|
|
467
|
-
|
|
468
499
|
adder = default_scope == :class ? :add_class_method : :add_instance_method
|
|
469
500
|
names.each do |name|
|
|
470
501
|
kinds = message == :attribute ? %i[reader writer] : ATTR_MESSAGES.fetch(message, %i[reader])
|
|
@@ -479,7 +510,9 @@ module ArchSpec
|
|
|
479
510
|
def generated_method_names(constant, node, message)
|
|
480
511
|
names = symbol_arguments(node)
|
|
481
512
|
return names unless message == :attribute
|
|
482
|
-
|
|
513
|
+
|
|
514
|
+
superclass = constant.superclass.to_s.sub(/\A::/, '')
|
|
515
|
+
return names if superclass == 'ActiveSupport::CurrentAttributes'
|
|
483
516
|
|
|
484
517
|
names.first(1)
|
|
485
518
|
end
|
|
@@ -490,11 +523,13 @@ module ArchSpec
|
|
|
490
523
|
end || []
|
|
491
524
|
end
|
|
492
525
|
|
|
493
|
-
def
|
|
526
|
+
def truthy_keyword_argument?(node, name)
|
|
494
527
|
node.arguments&.arguments&.any? do |argument|
|
|
495
528
|
argument.is_a?(Prism::KeywordHashNode) && argument.elements.any? do |element|
|
|
496
529
|
element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
|
|
497
|
-
element.key.unescaped.to_sym == name
|
|
530
|
+
element.key.unescaped.to_sym == name &&
|
|
531
|
+
!element.value.is_a?(Prism::FalseNode) &&
|
|
532
|
+
!element.value.is_a?(Prism::NilNode)
|
|
498
533
|
end
|
|
499
534
|
end
|
|
500
535
|
end
|
|
@@ -512,7 +547,11 @@ module ArchSpec
|
|
|
512
547
|
return unless receiver.is_a?(Prism::CallNode) && receiver.message&.to_sym == :new
|
|
513
548
|
|
|
514
549
|
receiver_node = receiver.receiver
|
|
515
|
-
|
|
550
|
+
return unless constant_node?(receiver_node)
|
|
551
|
+
|
|
552
|
+
name = constant_reference_name(receiver_node)
|
|
553
|
+
return unless name
|
|
554
|
+
|
|
516
555
|
"#{name}##{node.message}"
|
|
517
556
|
end
|
|
518
557
|
|
|
@@ -520,7 +559,9 @@ module ArchSpec
|
|
|
520
559
|
# Admin::Users::RolesController, so compact paths join the namespace too.
|
|
521
560
|
def qualified_constant_name(node, namespace)
|
|
522
561
|
raw = constant_reference_name(node)
|
|
523
|
-
|
|
562
|
+
return unless raw
|
|
563
|
+
|
|
564
|
+
absolute = node.is_a?(Prism::ConstantPathNode) && node.parent.nil?
|
|
524
565
|
|
|
525
566
|
if absolute || namespace.empty?
|
|
526
567
|
raw
|
|
@@ -532,8 +573,11 @@ module ArchSpec
|
|
|
532
573
|
# nil when the path has dynamic parts (self.class::FOO): there is no
|
|
533
574
|
# static name to check against.
|
|
534
575
|
def constant_reference_name(node)
|
|
535
|
-
node
|
|
536
|
-
|
|
576
|
+
return unless constant_node?(node)
|
|
577
|
+
|
|
578
|
+
node.full_name.to_s
|
|
579
|
+
rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError,
|
|
580
|
+
Prism::ConstantPathNode::MissingNodesInConstantPathError
|
|
537
581
|
nil
|
|
538
582
|
end
|
|
539
583
|
|
|
@@ -11,35 +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
|
|
15
|
-
#
|
|
16
|
-
# +controller_api:+, +share_helpers:+.
|
|
14
|
+
# - +:rails+: conventional MVC that keeps controller APIs out of models and
|
|
15
|
+
# services. Options +components:+, +controller_api:+, +share_helpers:+.
|
|
17
16
|
# - +:rails_strict+: +:rails+ plus a cycle check and a concern independence
|
|
18
17
|
# check. Adds option +concerns:+.
|
|
19
18
|
# - +:vanilla_rails+: +:rails+ plus empty-directory rules for the 37signals
|
|
20
19
|
# style (forbidding +app/services+, +app/forms+, +app/policies+, and more)
|
|
21
20
|
# and the concern independence check. Options +components:+, +empty:+,
|
|
22
21
|
# +controller_api:+, +share_helpers:+, +concerns:+.
|
|
23
|
-
# - +:layered
|
|
24
|
-
#
|
|
25
|
-
# - +:hexagonal
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
# +
|
|
36
|
-
# - +:event_driven+ (alias +:rails_event_driven+): events, publishers, and
|
|
37
|
-
# subscribers. Options +events:+, +publishers:+, +subscribers:+.
|
|
22
|
+
# - +:layered+: ordered layers that may only depend inward, with a cycle
|
|
23
|
+
# check. Option +layers:+ (order matters).
|
|
24
|
+
# - +:hexagonal+: ports and adapters, keeping the domain away from adapters.
|
|
25
|
+
# Options +application:+, +domain:+, +ports:+, +adapters:+.
|
|
26
|
+
# - +:clean+: clean architecture layers. Options +frameworks:+,
|
|
27
|
+
# +interface_adapters:+, +use_cases:+, +entities:+.
|
|
28
|
+
# - +:modular_monolith+: named packages with per-package allowlists and
|
|
29
|
+
# optional public APIs. Options +components:+ (required), +allow:+,
|
|
30
|
+
# +public:+.
|
|
31
|
+
# - +:cqrs+: separates commands from queries and keeps writes out of queries.
|
|
32
|
+
# Options +commands:+, +queries:+, +read_models:+, +mutating_methods:+.
|
|
33
|
+
# - +:event_driven+: events, publishers, and subscribers. Options +events:+,
|
|
34
|
+
# +publishers:+, +subscribers:+.
|
|
38
35
|
# - +:ruby_conventions+: generic Ruby naming idioms (no +get_+/+set_+, no +is_+
|
|
39
36
|
# prefix), applied project-wide. Adds no components, so it composes with any
|
|
40
37
|
# other architecture. No options.
|
|
41
38
|
#
|
|
42
|
-
# See the guides
|
|
39
|
+
# See the {architecture guides}[https://archspecrb.dev/architectures/] for
|
|
40
|
+
# each preset in depth.
|
|
43
41
|
module Architectures
|
|
44
42
|
extend self
|
|
45
43
|
|
|
@@ -106,84 +104,77 @@ module ArchSpec
|
|
|
106
104
|
upsert upsert!
|
|
107
105
|
].freeze
|
|
108
106
|
|
|
107
|
+
# Every option each architecture accepts, with its default. The single
|
|
108
|
+
# source of truth for #apply: option validation checks these keys, and the
|
|
109
|
+
# architecture methods receive these values merged with the caller's.
|
|
110
|
+
DEFAULTS = {
|
|
111
|
+
rails: {
|
|
112
|
+
components: DEFAULT_RAILS_MVC,
|
|
113
|
+
controller_api: CONTROLLER_METHODS,
|
|
114
|
+
share_helpers: false
|
|
115
|
+
},
|
|
116
|
+
rails_strict: {
|
|
117
|
+
components: DEFAULT_RAILS_MVC,
|
|
118
|
+
controller_api: CONTROLLER_METHODS,
|
|
119
|
+
share_helpers: false,
|
|
120
|
+
concerns: DEFAULT_CONCERNS
|
|
121
|
+
},
|
|
122
|
+
vanilla_rails: {
|
|
123
|
+
components: DEFAULT_RAILS_MVC,
|
|
124
|
+
empty: VANILLA_RAILS_EMPTY,
|
|
125
|
+
controller_api: CONTROLLER_METHODS,
|
|
126
|
+
share_helpers: false,
|
|
127
|
+
concerns: DEFAULT_CONCERNS
|
|
128
|
+
},
|
|
129
|
+
layered: { layers: DEFAULT_LAYERED },
|
|
130
|
+
hexagonal: DEFAULT_HEXAGONAL,
|
|
131
|
+
clean: DEFAULT_CLEAN,
|
|
132
|
+
modular_monolith: { components: nil, allow: {}, public: {} },
|
|
133
|
+
cqrs: DEFAULT_CQRS.merge(mutating_methods: MUTATING_METHODS),
|
|
134
|
+
event_driven: DEFAULT_EVENT_DRIVEN,
|
|
135
|
+
ruby_conventions: {}
|
|
136
|
+
}.freeze
|
|
137
|
+
|
|
109
138
|
# Applies the named preset to +dsl+, forwarding +options+ to it. Raises
|
|
110
139
|
# ArchSpec::Error for an unknown name. Called by
|
|
111
140
|
# ArchSpec::DSL::Context#architecture, so you rarely call it directly.
|
|
112
141
|
def apply(name, dsl, **options)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
share_helpers: options.fetch(:share_helpers, false)
|
|
120
|
-
)
|
|
121
|
-
when :rails_strict
|
|
122
|
-
rails_strict(
|
|
123
|
-
dsl,
|
|
124
|
-
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
125
|
-
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
126
|
-
share_helpers: options.fetch(:share_helpers, false),
|
|
127
|
-
concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
|
|
128
|
-
)
|
|
129
|
-
when :vanilla_rails
|
|
130
|
-
vanilla_rails(
|
|
131
|
-
dsl,
|
|
132
|
-
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
133
|
-
empty: options.fetch(:empty, VANILLA_RAILS_EMPTY),
|
|
134
|
-
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
135
|
-
share_helpers: options.fetch(:share_helpers, false),
|
|
136
|
-
concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
|
|
137
|
-
)
|
|
138
|
-
when :layered, :rails_layered
|
|
139
|
-
layered(dsl, layers: options.fetch(:layers, DEFAULT_LAYERED))
|
|
140
|
-
when :hexagonal, :rails_hexagonal
|
|
141
|
-
hexagonal(dsl, **with_defaults(DEFAULT_HEXAGONAL, options))
|
|
142
|
-
when :clean, :rails_clean
|
|
143
|
-
clean(dsl, **with_defaults(DEFAULT_CLEAN, options))
|
|
144
|
-
when :modular_monolith, :bounded_contexts
|
|
145
|
-
modular_monolith(
|
|
146
|
-
dsl,
|
|
147
|
-
components: options.fetch(:components),
|
|
148
|
-
allow: options.fetch(:allow, {}),
|
|
149
|
-
public: options.fetch(:public, {})
|
|
150
|
-
)
|
|
151
|
-
when :cqrs, :rails_cqrs
|
|
152
|
-
cqrs(dsl, **with_defaults(DEFAULT_CQRS, options))
|
|
153
|
-
when :event_driven, :rails_event_driven
|
|
154
|
-
event_driven(dsl, **with_defaults(DEFAULT_EVENT_DRIVEN, options))
|
|
155
|
-
when :ruby_conventions
|
|
156
|
-
ruby_conventions(dsl)
|
|
157
|
-
else
|
|
158
|
-
raise Error, "Unknown ArchSpec architecture: #{name.inspect}"
|
|
159
|
-
end
|
|
142
|
+
name = architecture_name(name)
|
|
143
|
+
defaults = DEFAULTS[name]
|
|
144
|
+
raise Error, "unknown architecture: #{name.inspect}" unless defaults
|
|
145
|
+
|
|
146
|
+
validate_options!(name, defaults, options)
|
|
147
|
+
send(name, dsl, **defaults.merge(options))
|
|
160
148
|
end
|
|
161
149
|
|
|
162
|
-
def
|
|
150
|
+
def rails(dsl, components:, controller_api:, share_helpers:)
|
|
163
151
|
components = normalize_map(components)
|
|
152
|
+
missing = %i[controllers models] - components.keys
|
|
153
|
+
if missing.any?
|
|
154
|
+
raise Error, "the rails architectures need controllers and models components, missing: #{missing.join(', ')}"
|
|
155
|
+
end
|
|
156
|
+
|
|
164
157
|
define_components(dsl, components)
|
|
165
158
|
|
|
166
|
-
forbidden = share_helpers ? %i[controllers] : %i[controllers helpers]
|
|
159
|
+
forbidden = (share_helpers ? %i[controllers] : %i[controllers helpers]) & components.keys
|
|
167
160
|
proxy_for(dsl, :controllers).can_only_use(*components.keys & %i[models services helpers mailers jobs])
|
|
168
|
-
proxy_for(dsl, :models).cannot_use(*components.keys & forbidden)
|
|
169
|
-
proxy_for(dsl, :services).cannot_use(*components.keys & forbidden)
|
|
170
161
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
162
|
+
(%i[models services] & components.keys).each do |name|
|
|
163
|
+
proxy = proxy_for(dsl, name)
|
|
164
|
+
proxy.cannot_use(*forbidden)
|
|
165
|
+
proxy.cannot_call(*controller_api, receiver: :none) unless controller_api.empty?
|
|
166
|
+
end
|
|
175
167
|
end
|
|
176
168
|
|
|
177
|
-
def rails_strict(dsl, components:, controller_api
|
|
169
|
+
def rails_strict(dsl, components:, controller_api:, share_helpers:, concerns:)
|
|
178
170
|
components = normalize_map(components)
|
|
179
|
-
|
|
171
|
+
rails(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
180
172
|
dsl.no_cycles(among: components.keys)
|
|
181
173
|
independent_concerns(dsl, concerns)
|
|
182
174
|
end
|
|
183
175
|
|
|
184
|
-
def vanilla_rails(dsl, components:, empty:, controller_api
|
|
185
|
-
|
|
186
|
-
rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
176
|
+
def vanilla_rails(dsl, components:, empty:, controller_api:, share_helpers:, concerns:)
|
|
177
|
+
rails(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
187
178
|
|
|
188
179
|
empty.each do |name, (pattern, reason)|
|
|
189
180
|
dsl.component(name, in: pattern).must_be_empty(because: reason)
|
|
@@ -234,6 +225,8 @@ module ArchSpec
|
|
|
234
225
|
end
|
|
235
226
|
|
|
236
227
|
def modular_monolith(dsl, components:, allow: {}, public: {})
|
|
228
|
+
raise Error, 'architecture :modular_monolith requires the components: option' unless components
|
|
229
|
+
|
|
237
230
|
components = normalize_map(components)
|
|
238
231
|
define_components(dsl, components)
|
|
239
232
|
|
|
@@ -248,7 +241,7 @@ module ArchSpec
|
|
|
248
241
|
dsl.no_cycles(among: components.keys)
|
|
249
242
|
end
|
|
250
243
|
|
|
251
|
-
def cqrs(dsl, commands:, queries:, read_models
|
|
244
|
+
def cqrs(dsl, commands:, queries:, read_models:, mutating_methods:)
|
|
252
245
|
components = normalize_map(commands: commands, queries: queries)
|
|
253
246
|
components[:read_models] = read_models if read_models
|
|
254
247
|
define_components(dsl, components)
|
|
@@ -283,6 +276,21 @@ module ArchSpec
|
|
|
283
276
|
|
|
284
277
|
private
|
|
285
278
|
|
|
279
|
+
def architecture_name(name)
|
|
280
|
+
name.to_sym
|
|
281
|
+
rescue NoMethodError
|
|
282
|
+
raise Error, "unknown architecture: #{name.inspect}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def validate_options!(name, defaults, options)
|
|
286
|
+
unknown = options.keys - defaults.keys
|
|
287
|
+
return if unknown.empty?
|
|
288
|
+
|
|
289
|
+
label = unknown.length == 1 ? 'option' : 'options'
|
|
290
|
+
names = unknown.map { |option| "#{option}:" }.sort.join(', ')
|
|
291
|
+
raise Error, "unknown #{label} for architecture :#{name}: #{names}"
|
|
292
|
+
end
|
|
293
|
+
|
|
286
294
|
def forbid_name(dsl, regex, reason, scope:)
|
|
287
295
|
dsl.rule(
|
|
288
296
|
Rules::NamingRule.new(
|
|
@@ -294,10 +302,6 @@ module ArchSpec
|
|
|
294
302
|
)
|
|
295
303
|
end
|
|
296
304
|
|
|
297
|
-
def with_defaults(defaults, options)
|
|
298
|
-
defaults.merge(options)
|
|
299
|
-
end
|
|
300
|
-
|
|
301
305
|
def normalize_map(map)
|
|
302
306
|
map.to_h.transform_keys(&:to_sym)
|
|
303
307
|
end
|