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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5ac200fd85432184c5043ce7540e45cb3bcc0013a37ecd77d07c34aca51bfd8
4
- data.tar.gz: 5d3e6b1558c796c6784505b9637b5fa58fb0da9d7b85fd30e71b7a26689da471
3
+ metadata.gz: 3b50ec4f86cfdf9e4f95b1cf4a28c4dec212d711781675dedd6648f13aed7c1f
4
+ data.tar.gz: 9303aefb00b161004cc515b0d1b2ad03b3e60f396ef34b9dc86be4e7a1825650
5
5
  SHA512:
6
- metadata.gz: 71b4a46c3423cd47b0317559aea01038431b29f0fa8cbd1f72a403cf5e4a3bb64bf7649412752110d7a49e5e59abbc387dd3e3ec1251a11a1f7219d6fdd05877
7
- data.tar.gz: 28f23d54d05d3d16663a5c728aa55846b45f240913cdada2954672059d9193e895c7e651434351bef50f30731ad08e8b5660680e17d97b294846745f769e7f75
6
+ metadata.gz: 56bd81e5ee880404a80c7f29d498c58903bd8d087c5e8deb80af060d6d169c3b40e471eb09758500d6522516d83090b435b45ab243f780978539840b9548681e
7
+ data.tar.gz: a3dc2d41b6b97edd3ddacb8c84632c0b8ae81471c9132ec02ddbf66ce68d8dd916d068aeb366a8bed99f996ed6f81cda8a3a48b502459b0d25afc525d81df95d
data/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # ArchSpec
2
2
 
3
- Architecture linter for Ruby and Rails.
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 reads Ruby source
8
- with Prism and never boots the app.
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] app/models/user.rb:2:3
186
- models must not depend on controllers
187
- evidence: User references_constant UsersController
188
- confidence: high
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
@@ -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
- active[rule] << [line + 1, reason]
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: [], visibility: :public, default_scope: :instance)
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
- visibility: visibility, default_scope: default_scope)
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
- namespace: constant.name.split('::'))
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: current_constant, namespace: namespace,
233
- visibility: visibility, default_scope: 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, default_scope: :instance)
270
- if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
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, default_scope: :instance)
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
- visibility: visibility, default_scope: default_scope)
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, default_scope: :instance)
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
- visibility: current, default_scope: default_scope)
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
- return names if constant.superclass == 'ActiveSupport::CurrentAttributes'
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 keyword_argument?(node, name)
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
- name = (constant_reference_name(receiver_node) if constant_node?(receiver_node)) || receiver_node&.slice
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
- absolute = node.respond_to?(:full_name_parts) && node.full_name_parts.first == :""
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.full_name.to_s.sub(/\A::/, '')
536
- rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
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+ (aliases +:rails_mvc+, +:rails_way+): conventional MVC that keeps
15
- # controller APIs out of models and services. Options +components:+,
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+ (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:+.
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 at https://archspecrb.dev/architectures/ for each in depth.
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
- case name.to_sym
114
- when :rails, :rails_mvc, :rails_way
115
- rails_mvc(
116
- dsl,
117
- components: options.fetch(:components, DEFAULT_RAILS_MVC),
118
- controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
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 rails_mvc(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false)
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
- return if controller_api.empty?
172
-
173
- proxy_for(dsl, :models).cannot_call(*controller_api, receiver: :none)
174
- proxy_for(dsl, :services).cannot_call(*controller_api, receiver: :none)
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: CONTROLLER_METHODS, share_helpers: false, concerns: DEFAULT_CONCERNS)
169
+ def rails_strict(dsl, components:, controller_api:, share_helpers:, concerns:)
178
170
  components = normalize_map(components)
179
- rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
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: CONTROLLER_METHODS, share_helpers: false,
185
- concerns: DEFAULT_CONCERNS)
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: nil, mutating_methods: MUTATING_METHODS)
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