archspec 0.5.0 → 1.0.0.rc1
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 +22 -5
- data/lib/archspec/analyzer.rb +97 -53
- data/lib/archspec/architectures.rb +86 -83
- 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: 24d73489bf5bdd8a56deffac28bbcd11aab271db3281afcce4e0aa21c93de28b
|
|
4
|
+
data.tar.gz: 9c6303777aededa6ce4f7780943a5a7257b33cbef978e50369296d1a4d0b0a8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c69995b8ce63a81ed15be9233a4f6199deb5faa710a0853613150d464229b653a4d7e122b032ba417e6048b7cf1285ee6a9272ff0217071b8cd727ed02add63
|
|
7
|
+
data.tar.gz: 8de9a4380f15a1c8f197980207ba20939bfac7a9a7a61e9e297eb57b517c491eb9017ab91c22057b3d88536c609bac385a6fddc311cb76b8915b66c64c5dc9dc
|
data/README.md
CHANGED
|
@@ -164,7 +164,18 @@ bundle exec archspec explain app/models/user.rb
|
|
|
164
164
|
```
|
|
165
165
|
|
|
166
166
|
`explain` shows why a file or constant belongs to a component and which outgoing
|
|
167
|
-
facts ArchSpec found
|
|
167
|
+
facts ArchSpec found:
|
|
168
|
+
|
|
169
|
+
```text
|
|
170
|
+
app/models/user.rb
|
|
171
|
+
|
|
172
|
+
defined constants: User
|
|
173
|
+
components:
|
|
174
|
+
models: matched file pattern app/models/**/*.rb
|
|
175
|
+
outgoing facts:
|
|
176
|
+
1:14 │ inherits from ApplicationRecord
|
|
177
|
+
2:3 │ references UsersController
|
|
178
|
+
```
|
|
168
179
|
|
|
169
180
|
## Checking AI-Written Code
|
|
170
181
|
|
|
@@ -182,10 +193,16 @@ Passing paths still analyzes the project so dependencies resolve, but reports on
|
|
|
182
193
|
If it fails, read the evidence before changing the spec:
|
|
183
194
|
|
|
184
195
|
```text
|
|
185
|
-
[dependencies.forbid]
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
196
|
+
[error] models must not depend on controllers [dependencies.forbid]
|
|
197
|
+
|
|
198
|
+
app/models/user.rb:2:3
|
|
199
|
+
|
|
200
|
+
1 │ class User < ApplicationRecord
|
|
201
|
+
→ 2 │ UsersController
|
|
202
|
+
│ ^~~~~~~~~~~~~~~
|
|
203
|
+
3 │ end
|
|
204
|
+
|
|
205
|
+
note: User references UsersController
|
|
189
206
|
```
|
|
190
207
|
|
|
191
208
|
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,30 +11,27 @@ 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.
|
|
@@ -106,84 +103,77 @@ module ArchSpec
|
|
|
106
103
|
upsert upsert!
|
|
107
104
|
].freeze
|
|
108
105
|
|
|
106
|
+
# Every option each architecture accepts, with its default. The single
|
|
107
|
+
# source of truth for #apply: option validation checks these keys, and the
|
|
108
|
+
# architecture methods receive these values merged with the caller's.
|
|
109
|
+
DEFAULTS = {
|
|
110
|
+
rails: {
|
|
111
|
+
components: DEFAULT_RAILS_MVC,
|
|
112
|
+
controller_api: CONTROLLER_METHODS,
|
|
113
|
+
share_helpers: false
|
|
114
|
+
},
|
|
115
|
+
rails_strict: {
|
|
116
|
+
components: DEFAULT_RAILS_MVC,
|
|
117
|
+
controller_api: CONTROLLER_METHODS,
|
|
118
|
+
share_helpers: false,
|
|
119
|
+
concerns: DEFAULT_CONCERNS
|
|
120
|
+
},
|
|
121
|
+
vanilla_rails: {
|
|
122
|
+
components: DEFAULT_RAILS_MVC,
|
|
123
|
+
empty: VANILLA_RAILS_EMPTY,
|
|
124
|
+
controller_api: CONTROLLER_METHODS,
|
|
125
|
+
share_helpers: false,
|
|
126
|
+
concerns: DEFAULT_CONCERNS
|
|
127
|
+
},
|
|
128
|
+
layered: { layers: DEFAULT_LAYERED },
|
|
129
|
+
hexagonal: DEFAULT_HEXAGONAL,
|
|
130
|
+
clean: DEFAULT_CLEAN,
|
|
131
|
+
modular_monolith: { components: nil, allow: {}, public: {} },
|
|
132
|
+
cqrs: DEFAULT_CQRS.merge(mutating_methods: MUTATING_METHODS),
|
|
133
|
+
event_driven: DEFAULT_EVENT_DRIVEN,
|
|
134
|
+
ruby_conventions: {}
|
|
135
|
+
}.freeze
|
|
136
|
+
|
|
109
137
|
# Applies the named preset to +dsl+, forwarding +options+ to it. Raises
|
|
110
138
|
# ArchSpec::Error for an unknown name. Called by
|
|
111
139
|
# ArchSpec::DSL::Context#architecture, so you rarely call it directly.
|
|
112
140
|
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
|
|
141
|
+
name = architecture_name(name)
|
|
142
|
+
defaults = DEFAULTS[name]
|
|
143
|
+
raise Error, "unknown architecture: #{name.inspect}" unless defaults
|
|
144
|
+
|
|
145
|
+
validate_options!(name, defaults, options)
|
|
146
|
+
send(name, dsl, **defaults.merge(options))
|
|
160
147
|
end
|
|
161
148
|
|
|
162
|
-
def
|
|
149
|
+
def rails(dsl, components:, controller_api:, share_helpers:)
|
|
163
150
|
components = normalize_map(components)
|
|
151
|
+
missing = %i[controllers models] - components.keys
|
|
152
|
+
if missing.any?
|
|
153
|
+
raise Error, "the rails architectures need controllers and models components, missing: #{missing.join(', ')}"
|
|
154
|
+
end
|
|
155
|
+
|
|
164
156
|
define_components(dsl, components)
|
|
165
157
|
|
|
166
|
-
forbidden = share_helpers ? %i[controllers] : %i[controllers helpers]
|
|
158
|
+
forbidden = (share_helpers ? %i[controllers] : %i[controllers helpers]) & components.keys
|
|
167
159
|
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
160
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
161
|
+
(%i[models services] & components.keys).each do |name|
|
|
162
|
+
proxy = proxy_for(dsl, name)
|
|
163
|
+
proxy.cannot_use(*forbidden)
|
|
164
|
+
proxy.cannot_call(*controller_api, receiver: :none) unless controller_api.empty?
|
|
165
|
+
end
|
|
175
166
|
end
|
|
176
167
|
|
|
177
|
-
def rails_strict(dsl, components:, controller_api
|
|
168
|
+
def rails_strict(dsl, components:, controller_api:, share_helpers:, concerns:)
|
|
178
169
|
components = normalize_map(components)
|
|
179
|
-
|
|
170
|
+
rails(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
180
171
|
dsl.no_cycles(among: components.keys)
|
|
181
172
|
independent_concerns(dsl, concerns)
|
|
182
173
|
end
|
|
183
174
|
|
|
184
|
-
def vanilla_rails(dsl, components:, empty:, controller_api
|
|
185
|
-
|
|
186
|
-
rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
175
|
+
def vanilla_rails(dsl, components:, empty:, controller_api:, share_helpers:, concerns:)
|
|
176
|
+
rails(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
187
177
|
|
|
188
178
|
empty.each do |name, (pattern, reason)|
|
|
189
179
|
dsl.component(name, in: pattern).must_be_empty(because: reason)
|
|
@@ -234,6 +224,8 @@ module ArchSpec
|
|
|
234
224
|
end
|
|
235
225
|
|
|
236
226
|
def modular_monolith(dsl, components:, allow: {}, public: {})
|
|
227
|
+
raise Error, 'architecture :modular_monolith requires the components: option' unless components
|
|
228
|
+
|
|
237
229
|
components = normalize_map(components)
|
|
238
230
|
define_components(dsl, components)
|
|
239
231
|
|
|
@@ -248,7 +240,7 @@ module ArchSpec
|
|
|
248
240
|
dsl.no_cycles(among: components.keys)
|
|
249
241
|
end
|
|
250
242
|
|
|
251
|
-
def cqrs(dsl, commands:, queries:, read_models
|
|
243
|
+
def cqrs(dsl, commands:, queries:, read_models:, mutating_methods:)
|
|
252
244
|
components = normalize_map(commands: commands, queries: queries)
|
|
253
245
|
components[:read_models] = read_models if read_models
|
|
254
246
|
define_components(dsl, components)
|
|
@@ -283,6 +275,21 @@ module ArchSpec
|
|
|
283
275
|
|
|
284
276
|
private
|
|
285
277
|
|
|
278
|
+
def architecture_name(name)
|
|
279
|
+
name.to_sym
|
|
280
|
+
rescue NoMethodError
|
|
281
|
+
raise Error, "unknown architecture: #{name.inspect}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def validate_options!(name, defaults, options)
|
|
285
|
+
unknown = options.keys - defaults.keys
|
|
286
|
+
return if unknown.empty?
|
|
287
|
+
|
|
288
|
+
label = unknown.length == 1 ? 'option' : 'options'
|
|
289
|
+
names = unknown.map { |option| "#{option}:" }.sort.join(', ')
|
|
290
|
+
raise Error, "unknown #{label} for architecture :#{name}: #{names}"
|
|
291
|
+
end
|
|
292
|
+
|
|
286
293
|
def forbid_name(dsl, regex, reason, scope:)
|
|
287
294
|
dsl.rule(
|
|
288
295
|
Rules::NamingRule.new(
|
|
@@ -294,10 +301,6 @@ module ArchSpec
|
|
|
294
301
|
)
|
|
295
302
|
end
|
|
296
303
|
|
|
297
|
-
def with_defaults(defaults, options)
|
|
298
|
-
defaults.merge(options)
|
|
299
|
-
end
|
|
300
|
-
|
|
301
304
|
def normalize_map(map)
|
|
302
305
|
map.to_h.transform_keys(&:to_sym)
|
|
303
306
|
end
|