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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5ac200fd85432184c5043ce7540e45cb3bcc0013a37ecd77d07c34aca51bfd8
4
- data.tar.gz: 5d3e6b1558c796c6784505b9637b5fa58fb0da9d7b85fd30e71b7a26689da471
3
+ metadata.gz: 24d73489bf5bdd8a56deffac28bbcd11aab271db3281afcce4e0aa21c93de28b
4
+ data.tar.gz: 9c6303777aededa6ce4f7780943a5a7257b33cbef978e50369296d1a4d0b0a8f
5
5
  SHA512:
6
- metadata.gz: 71b4a46c3423cd47b0317559aea01038431b29f0fa8cbd1f72a403cf5e4a3bb64bf7649412752110d7a49e5e59abbc387dd3e3ec1251a11a1f7219d6fdd05877
7
- data.tar.gz: 28f23d54d05d3d16663a5c728aa55846b45f240913cdada2954672059d9193e895c7e651434351bef50f30731ad08e8b5660680e17d97b294846745f769e7f75
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] app/models/user.rb:2:3
186
- models must not depend on controllers
187
- evidence: User references_constant UsersController
188
- confidence: high
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
@@ -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,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+ (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.
@@ -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
- 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
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 rails_mvc(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false)
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
- 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)
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: CONTROLLER_METHODS, share_helpers: false, concerns: DEFAULT_CONCERNS)
168
+ def rails_strict(dsl, components:, controller_api:, share_helpers:, concerns:)
178
169
  components = normalize_map(components)
179
- rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
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: CONTROLLER_METHODS, share_helpers: false,
185
- concerns: DEFAULT_CONCERNS)
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: nil, mutating_methods: MUTATING_METHODS)
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