archspec 0.4.0 → 0.5.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: 25ea714cd768976e860df4f0dbaa9ff477e381096f95b98787a0a645fc0cd776
4
- data.tar.gz: 527621e2bb18514433cf26ede594f2cafa2699d0d95e2d3fa4d9e8b9e4aac354
3
+ metadata.gz: b5ac200fd85432184c5043ce7540e45cb3bcc0013a37ecd77d07c34aca51bfd8
4
+ data.tar.gz: 5d3e6b1558c796c6784505b9637b5fa58fb0da9d7b85fd30e71b7a26689da471
5
5
  SHA512:
6
- metadata.gz: f9c83e97692a44bbf72391593e695bc4c8a7859d148508f4cb8cf490a55e51a8de98d0df8ab0d37bf8bf13fcdb3e4707f9325850a39b9a33ea678a0d0aa7f3b2
7
- data.tar.gz: ec19d8b85144cee92cd7bd2c968d848aef39feccdda87b56849dab7ac706543fec8041bee524ab0c5532193b423442f6ef3eec00d6d52f6301ec3e960b5cf1b8
6
+ metadata.gz: 71b4a46c3423cd47b0317559aea01038431b29f0fa8cbd1f72a403cf5e4a3bb64bf7649412752110d7a49e5e59abbc387dd3e3ec1251a11a1f7219d6fdd05877
7
+ data.tar.gz: 28f23d54d05d3d16663a5c728aa55846b45f240913cdada2954672059d9193e895c7e651434351bef50f30731ad08e8b5660680e17d97b294846745f769e7f75
data/README.md CHANGED
@@ -92,7 +92,7 @@ component :controllers, in: "app/controllers/**/*.rb"
92
92
  component :models, in: "app/models/**/*.rb"
93
93
  component :services, in: "app/services/**/*.rb"
94
94
 
95
- controllers.can_use :models, :services
95
+ controllers.can_only_use :models, :services
96
96
  models.cannot_use :controllers
97
97
  services.cannot_call :render, :redirect_to, :params, :session
98
98
  services.cannot_instantiate_and_invoke
@@ -114,13 +114,17 @@ architecture :cqrs,
114
114
  - **Concerns:** a concern must not depend on the classes that include it
115
115
  - **Layers:** dependency direction and cycles
116
116
  - **Rails:** controller APIs kept out of models and services
117
- - **Architectures:** Rails, vanilla Rails, layered, hexagonal, clean, modular monolith, CQRS, and event-driven bundles
117
+ - **Architectures:** Rails, vanilla Rails, layered, hexagonal, clean, modular monolith, CQRS, event-driven, and Ruby conventions bundles
118
118
  - **Protocols:** required methods such as `resolve`, `perform`, or project-specific interfaces
119
+ - **Naming:** conventions on a component's public API, such as banning `get_`/`set_` or pairing `with_x` with `without_x`
119
120
  - **Objects:** rules against one-shot `Something.new(...).whatever` command objects
120
- - **Zeitwerk names:** conventional file names defining the expected constants
121
121
  - **Empty components:** directories that must stay empty, like `app/services` in vanilla Rails
122
122
  - **Suppressions:** narrow local exceptions with a reason
123
123
 
124
+ ## Checking Zeitwerk Names
125
+
126
+ ArchSpec does not check Zeitwerk constant names. Zeitwerk does that itself. Add `Zeitwerk::Loader.eager_load_all` (or your loader's `eager_load`) to your test suite or CI. It raises on any file that does not define the constant its path implies, using your real inflector and ignores.
127
+
124
128
  ## Installation
125
129
 
126
130
  Add ArchSpec to your Gemfile:
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'prism'
4
- require 'pathname'
5
4
 
6
5
  module ArchSpec
7
6
  module Analyzer
@@ -15,7 +14,6 @@ module ArchSpec
15
14
  result = Prism.parse_file(path)
16
15
  graph.add_file(
17
16
  path: path,
18
- expected_constant: expected_constant_for(path, root, definition.inflections),
19
17
  parse_errors: parse_errors_for(path, result.errors),
20
18
  suppressions: suppressions_for(result.comments)
21
19
  )
@@ -49,38 +47,6 @@ module ArchSpec
49
47
  end.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }.to_set
50
48
  end
51
49
 
52
- def expected_constant_for(path, root, inflections = {})
53
- relative = Pathname(path).relative_path_from(Pathname(root)).to_s
54
- stem =
55
- case relative
56
- when %r{\Aapp/[^/]+/concerns/(.+)\.rb\z}
57
- Regexp.last_match(1)
58
- when %r{\Aapp/[^/]+/(.+)\.rb\z}
59
- Regexp.last_match(1)
60
- when %r{\Alib/(.+)\.rb\z}
61
- Regexp.last_match(1)
62
- when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/concerns/(.+)\.rb\z}
63
- Regexp.last_match(1)
64
- when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/(.+)\.rb\z}
65
- Regexp.last_match(1)
66
- else
67
- return nil
68
- end
69
-
70
- camelize_path(stem, inflections)
71
- end
72
-
73
- # Rails-style acronyms: inflections apply to whole path segments first,
74
- # then to each snake_case word (inflect "api" => "API" fixes both api.rb
75
- # and api_client.rb).
76
- def camelize_path(path, inflections = {})
77
- path.split('/').map do |part|
78
- inflections[part] || part.split('_').map do |word|
79
- inflections[word] || (word[0] ? word[0].upcase + word[1..] : word)
80
- end.join
81
- end.join('::')
82
- end
83
-
84
50
  def suppressions_for(comments)
85
51
  SuppressionParser.parse(comments)
86
52
  end
@@ -175,24 +141,31 @@ module ArchSpec
175
141
  attr_accessor: %i[reader writer]
176
142
  }.freeze
177
143
 
178
- def visit(graph, path, node, current_constant: nil, namespace: [])
144
+ def visit(graph, path, node, current_constant: nil, namespace: [], visibility: :public, default_scope: :instance)
179
145
  return unless node
180
146
 
181
147
  case node
182
148
  when Prism::ProgramNode, Prism::StatementsNode
183
- visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
149
+ visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
150
+ visibility: visibility, default_scope: default_scope)
184
151
  when Prism::ClassNode
185
152
  visit_class(graph, path, node, current_constant: current_constant, namespace: namespace)
186
153
  when Prism::ModuleNode
187
154
  visit_module(graph, path, node, current_constant: current_constant, namespace: namespace)
155
+ when Prism::SingletonClassNode
156
+ visit_singleton_class(graph, path, node, current_constant: current_constant, namespace: namespace,
157
+ visibility: visibility, default_scope: default_scope)
188
158
  when Prism::DefNode
189
- visit_def(graph, path, node, current_constant: current_constant, namespace: namespace)
159
+ visit_def(graph, path, node, current_constant: current_constant, namespace: namespace,
160
+ visibility: visibility, default_scope: default_scope)
190
161
  when Prism::CallNode
191
- visit_call(graph, path, node, current_constant: current_constant, namespace: namespace)
162
+ visit_call(graph, path, node, current_constant: current_constant, namespace: namespace,
163
+ visibility: visibility, default_scope: default_scope)
192
164
  when Prism::ConstantPathNode, Prism::ConstantReadNode
193
165
  add_constant_reference(graph, path, node, current_constant)
194
166
  else
195
- visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
167
+ visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
168
+ visibility: visibility, default_scope: default_scope)
196
169
  end
197
170
  end
198
171
 
@@ -229,7 +202,7 @@ module ArchSpec
229
202
  end
230
203
  end
231
204
 
232
- visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
205
+ visit_constant_body(graph, path, constant, node.body, constant.name.split('::'))
233
206
  end
234
207
 
235
208
  def visit_module(graph, path, node, current_constant:, namespace:)
@@ -241,17 +214,67 @@ module ArchSpec
241
214
  location: SourceLocation.from_prism(path, node.location)
242
215
  )
243
216
 
244
- visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
217
+ visit_constant_body(graph, path, constant, node.body, constant.name.split('::'))
218
+ end
219
+
220
+ # A +class << self+ (or +class << SomeConstant+) block defines methods on a
221
+ # constant's singleton, so they are class methods, and +private+ inside it
222
+ # applies to them. Walk the body with +default_scope: :class+ against the
223
+ # target constant. An unknown target (+class << variable+) still has its
224
+ # body visited for edges, but no methods are attributed.
225
+ def visit_singleton_class(graph, path, node, current_constant:, namespace:, visibility:, default_scope:)
226
+ target = singleton_target(node.expression, current_constant)
227
+ constant = target && graph.constants_named(target).find { |candidate| candidate.path == path }
228
+
229
+ if constant
230
+ visit_constant_body(graph, path, constant, node.body, namespace, default_scope: :class)
231
+ else
232
+ visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
233
+ visibility: visibility, default_scope: default_scope)
234
+ end
235
+ end
236
+
237
+ def singleton_target(expression, current_constant)
238
+ return current_constant if expression.is_a?(Prism::SelfNode)
239
+
240
+ constant_reference_name(expression) if constant_node?(expression)
241
+ end
242
+
243
+ # Walks a class or module body in source order, tracking method visibility
244
+ # so +private+/+protected+ and their inline and symbol-list forms mark the
245
+ # methods they cover. +default_scope+ is +:class+ inside a singleton class,
246
+ # so bare +def+s there are class methods. Every statement is still handed to
247
+ # +visit+, so all other facts (calls, references, mixins) are recorded
248
+ # exactly as before.
249
+ def visit_constant_body(graph, path, constant, body, namespace, default_scope: :instance)
250
+ return unless body
251
+
252
+ unless body.is_a?(Prism::StatementsNode)
253
+ return visit(graph, path, body, current_constant: constant.name, namespace: namespace,
254
+ default_scope: default_scope)
255
+ end
256
+
257
+ visibility = :public
258
+ body.body.each do |statement|
259
+ if (modifier = visibility_modifier(statement))
260
+ visibility = apply_visibility_modifier(graph, path, constant, statement, namespace, visibility, modifier,
261
+ default_scope)
262
+ else
263
+ visit(graph, path, statement, current_constant: constant.name, namespace: namespace,
264
+ visibility: visibility, default_scope: default_scope)
265
+ end
266
+ end
245
267
  end
246
268
 
247
- def visit_def(graph, path, node, current_constant:, namespace:)
269
+ def visit_def(graph, path, node, current_constant:, namespace:, visibility: :public, default_scope: :instance)
248
270
  if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
249
271
  candidate.path == path
250
272
  end)
251
- if node.receiver
252
- constant.add_class_method(node.name, location: SourceLocation.from_prism(path, node.location))
273
+ location = SourceLocation.from_prism(path, node.location)
274
+ if node.receiver || default_scope == :class
275
+ constant.add_class_method(node.name, location: location, visibility: visibility)
253
276
  else
254
- constant.add_instance_method(node.name, location: SourceLocation.from_prism(path, node.location))
277
+ constant.add_instance_method(node.name, location: location, visibility: visibility)
255
278
  end
256
279
  end
257
280
 
@@ -269,10 +292,10 @@ module ArchSpec
269
292
  visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
270
293
  end
271
294
 
272
- def visit_call(graph, path, node, current_constant:, namespace:)
295
+ def visit_call(graph, path, node, current_constant:, namespace:, visibility: :public, default_scope: :instance)
273
296
  unless node.message
274
- return visit_children(graph, path, node, current_constant: current_constant,
275
- namespace: namespace)
297
+ return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
298
+ visibility: visibility, default_scope: default_scope)
276
299
  end
277
300
 
278
301
  message = node.message.to_sym
@@ -298,7 +321,7 @@ module ArchSpec
298
321
  )
299
322
  end
300
323
 
301
- record_generated_methods(graph, path, node, message, current_constant, location)
324
+ record_generated_methods(graph, path, node, message, current_constant, location, visibility, default_scope)
302
325
 
303
326
  if (edge_type = MIXIN_MESSAGES[message])
304
327
  constant_arguments(node).each do |constant_name|
@@ -338,7 +361,8 @@ module ArchSpec
338
361
  )
339
362
  end
340
363
 
341
- visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
364
+ visit_children(graph, path, node, current_constant: current_constant, namespace: namespace,
365
+ visibility: visibility, default_scope: default_scope)
342
366
  end
343
367
 
344
368
  def add_constant_reference(graph, path, node, current_constant)
@@ -354,9 +378,62 @@ module ArchSpec
354
378
  )
355
379
  end
356
380
 
357
- def visit_children(graph, path, node, current_constant:, namespace:)
381
+ def visit_children(graph, path, node, current_constant:, namespace:, visibility: :public, default_scope: :instance)
358
382
  node.child_nodes.compact.each do |child|
359
- visit(graph, path, child, current_constant: current_constant, namespace: namespace)
383
+ visit(graph, path, child, current_constant: current_constant, namespace: namespace,
384
+ visibility: visibility, default_scope: default_scope)
385
+ end
386
+ end
387
+
388
+ # Maps a visibility call to [visibility, forced_scope]. A nil forced_scope
389
+ # means the call follows the context (instance methods in a class body,
390
+ # class methods inside +class << self+); +*_class_method+ always targets
391
+ # class methods.
392
+ VISIBILITY_MODIFIERS = {
393
+ private: [:private, nil],
394
+ protected: [:protected, nil],
395
+ public: [:public, nil],
396
+ private_class_method: [:private, :class],
397
+ public_class_method: [:public, :class]
398
+ }.freeze
399
+
400
+ # The [visibility, forced_scope] a bare visibility call sets, or nil when
401
+ # the node is not one. Only receiverless calls count, so +obj.private+ is
402
+ # ignored.
403
+ def visibility_modifier(node)
404
+ return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
405
+
406
+ VISIBILITY_MODIFIERS[node.message&.to_sym]
407
+ end
408
+
409
+ # Applies a visibility call and returns the default visibility for the
410
+ # statements that follow it. A bare +private+ changes that default; the
411
+ # inline (+private def foo+) and symbol-list (+private :foo+) forms mark
412
+ # only the methods they name and leave the default unchanged.
413
+ def apply_visibility_modifier(graph, path, constant, node, namespace, current, spec, default_scope)
414
+ visibility, forced_scope = spec
415
+ scope = forced_scope || default_scope
416
+ arguments = node.arguments&.arguments || []
417
+ definitions = arguments.select { |argument| argument.is_a?(Prism::DefNode) }
418
+ names = arguments.select { |argument| argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode) }
419
+
420
+ if definitions.any?
421
+ visit(graph, path, node, current_constant: constant.name, namespace: namespace,
422
+ visibility: visibility, default_scope: default_scope)
423
+ current
424
+ elsif names.any?
425
+ names.each { |name| constant.set_visibility(name.unescaped.to_sym, scope, visibility) }
426
+ visit(graph, path, node, current_constant: constant.name, namespace: namespace,
427
+ visibility: current, default_scope: default_scope)
428
+ current
429
+ elsif forced_scope.nil?
430
+ visit(graph, path, node, current_constant: constant.name, namespace: namespace,
431
+ visibility: visibility, default_scope: default_scope)
432
+ visibility
433
+ else
434
+ visit(graph, path, node, current_constant: constant.name, namespace: namespace,
435
+ visibility: current, default_scope: default_scope)
436
+ current
360
437
  end
361
438
  end
362
439
 
@@ -376,25 +453,37 @@ module ArchSpec
376
453
  end || []
377
454
  end
378
455
 
379
- # attr_* and unprefixed delegate calls define instance methods; without
380
- # them, a class calling its own reader looks like a foreign call.
381
- def record_generated_methods(graph, path, node, message, current_constant, location)
456
+ # attr_*, Rails attribute, and unprefixed delegate calls define methods;
457
+ # without them, a class calling its own reader looks like a foreign call.
458
+ def record_generated_methods(graph, path, node, message, current_constant, location, visibility, default_scope)
382
459
  return unless current_constant && node.receiver.nil?
383
- return unless ATTR_MESSAGES.key?(message) || message == :delegate
460
+ return unless ATTR_MESSAGES.key?(message) || %i[attribute delegate].include?(message)
384
461
 
385
462
  constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path }
386
463
  return unless constant
387
464
 
388
- names = symbol_arguments(node)
465
+ names = generated_method_names(constant, node, message)
389
466
  return if message == :delegate && keyword_argument?(node, :prefix)
390
467
 
468
+ adder = default_scope == :class ? :add_class_method : :add_instance_method
391
469
  names.each do |name|
392
- kinds = ATTR_MESSAGES.fetch(message, %i[reader])
393
- constant.add_instance_method(name, location: location) if kinds.include?(:reader)
394
- constant.add_instance_method(:"#{name}=", location: location) if kinds.include?(:writer)
470
+ kinds = message == :attribute ? %i[reader writer] : ATTR_MESSAGES.fetch(message, %i[reader])
471
+ constant.public_send(adder, name, location: location, visibility: visibility) if kinds.include?(:reader)
472
+ constant.public_send(adder, :"#{name}=", location: location, visibility: visibility) if kinds.include?(:writer)
395
473
  end
396
474
  end
397
475
 
476
+ # Active Record and Active Model take one attribute name followed by an
477
+ # optional type, which may itself be a symbol. CurrentAttributes instead
478
+ # accepts any number of names, so retain every literal name there.
479
+ def generated_method_names(constant, node, message)
480
+ names = symbol_arguments(node)
481
+ return names unless message == :attribute
482
+ return names if constant.superclass == 'ActiveSupport::CurrentAttributes'
483
+
484
+ names.first(1)
485
+ end
486
+
398
487
  def symbol_arguments(node)
399
488
  node.arguments&.arguments&.filter_map do |argument|
400
489
  argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
@@ -11,30 +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. Keeps
15
- # controller APIs out of models and services. Options: +components:+,
14
+ # - +:rails+ (aliases +:rails_mvc+, +:rails_way+): conventional MVC that keeps
15
+ # controller APIs out of models and services. Options +components:+,
16
16
  # +controller_api:+, +share_helpers:+.
17
- # +:rails_strict+:: +:rails+ plus Zeitwerk name checks, a cycle check, and a
18
- # concern independence check. Options add +concerns:+.
19
- # +:vanilla_rails+:: +:rails+ plus empty-directory rules for the 37signals
20
- # style, forbidding +app/services+, +app/forms+, +app/policies+, and more,
21
- # and the concern independence check. Options: +components:+, +empty:+,
17
+ # - +:rails_strict+: +:rails+ plus a cycle check and a concern independence
18
+ # check. Adds option +concerns:+.
19
+ # - +:vanilla_rails+: +:rails+ plus empty-directory rules for the 37signals
20
+ # style (forbidding +app/services+, +app/forms+, +app/policies+, and more)
21
+ # and the concern independence check. Options +components:+, +empty:+,
22
22
  # +controller_api:+, +share_helpers:+, +concerns:+.
23
- # +:layered+ (alias +:rails_layered+):: Ordered layers that may only depend
24
- # inward, with a cycle check. Option: +layers:+ (order matters).
25
- # +:hexagonal+ (alias +:rails_hexagonal+):: Ports and adapters, keeping the
26
- # domain away from adapters. Options: +application:+, +domain:+, +ports:+,
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
27
  # +adapters:+.
28
- # +:clean+ (alias +:rails_clean+):: Clean architecture layers. Options:
28
+ # - +:clean+ (alias +:rails_clean+): clean architecture layers. Options
29
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:+
30
+ # - +:modular_monolith+ (alias +:bounded_contexts+): named packages with
31
+ # per-package allowlists and optional public APIs. Options +components:+
32
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:+,
33
+ # - +:cqrs+ (alias +:rails_cqrs+): separates commands from queries and keeps
34
+ # writes out of queries. Options +commands:+, +queries:+, +read_models:+,
35
35
  # +mutating_methods:+.
36
- # +:event_driven+ (alias +:rails_event_driven+):: Events, publishers, and
37
- # subscribers. Options: +events:+, +publishers:+, +subscribers:+.
36
+ # - +:event_driven+ (alias +:rails_event_driven+): events, publishers, and
37
+ # subscribers. Options +events:+, +publishers:+, +subscribers:+.
38
+ # - +:ruby_conventions+: generic Ruby naming idioms (no +get_+/+set_+, no +is_+
39
+ # prefix), applied project-wide. Adds no components, so it composes with any
40
+ # other architecture. No options.
38
41
  #
39
42
  # See the guides at https://archspecrb.dev/architectures/ for each in depth.
40
43
  module Architectures
@@ -149,6 +152,8 @@ module ArchSpec
149
152
  cqrs(dsl, **with_defaults(DEFAULT_CQRS, options))
150
153
  when :event_driven, :rails_event_driven
151
154
  event_driven(dsl, **with_defaults(DEFAULT_EVENT_DRIVEN, options))
155
+ when :ruby_conventions
156
+ ruby_conventions(dsl)
152
157
  else
153
158
  raise Error, "Unknown ArchSpec architecture: #{name.inspect}"
154
159
  end
@@ -159,7 +164,7 @@ module ArchSpec
159
164
  define_components(dsl, components)
160
165
 
161
166
  forbidden = share_helpers ? %i[controllers] : %i[controllers helpers]
162
- proxy_for(dsl, :controllers).can_use(*components.keys & %i[models services helpers mailers jobs])
167
+ proxy_for(dsl, :controllers).can_only_use(*components.keys & %i[models services helpers mailers jobs])
163
168
  proxy_for(dsl, :models).cannot_use(*components.keys & forbidden)
164
169
  proxy_for(dsl, :services).cannot_use(*components.keys & forbidden)
165
170
 
@@ -172,8 +177,7 @@ module ArchSpec
172
177
  def rails_strict(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false, concerns: DEFAULT_CONCERNS)
173
178
  components = normalize_map(components)
174
179
  rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
175
- dsl.verify_zeitwerk_names!
176
- dsl.no_cycles!(among: components.keys)
180
+ dsl.no_cycles(among: components.keys)
177
181
  independent_concerns(dsl, concerns)
178
182
  end
179
183
 
@@ -195,10 +199,10 @@ module ArchSpec
195
199
 
196
200
  names.each_with_index do |name, index|
197
201
  allowed = names[(index + 1)..] || []
198
- proxy_for(dsl, name).can_use(*allowed)
202
+ proxy_for(dsl, name).can_only_use(*allowed)
199
203
  end
200
204
 
201
- dsl.no_cycles!(among: names)
205
+ dsl.no_cycles(among: names)
202
206
  end
203
207
 
204
208
  def hexagonal(dsl, application:, domain:, ports:, adapters:)
@@ -210,11 +214,11 @@ module ArchSpec
210
214
  )
211
215
  define_components(dsl, roles)
212
216
 
213
- proxy_for(dsl, :application).can_use :domain, :ports
217
+ proxy_for(dsl, :application).can_only_use :domain, :ports
214
218
  proxy_for(dsl, :domain).cannot_use :adapters
215
219
  proxy_for(dsl, :ports).cannot_use :adapters
216
- proxy_for(dsl, :adapters).can_use :application, :domain, :ports
217
- dsl.no_cycles!(among: roles.keys)
220
+ proxy_for(dsl, :adapters).can_only_use :application, :domain, :ports
221
+ dsl.no_cycles(among: roles.keys)
218
222
  end
219
223
 
220
224
  def clean(dsl, frameworks:, interface_adapters:, use_cases:, entities:)
@@ -235,13 +239,13 @@ module ArchSpec
235
239
 
236
240
  components.each_key do |name|
237
241
  allowed = Array(allow[name] || allow[name.to_s])
238
- proxy_for(dsl, name).can_use(*allowed)
242
+ proxy_for(dsl, name).can_only_use(*allowed)
239
243
 
240
244
  patterns = Array(public[name] || public[name.to_s])
241
245
  proxy_for(dsl, name).public_api(*patterns) if patterns.any?
242
246
  end
243
247
 
244
- dsl.no_cycles!(among: components.keys)
248
+ dsl.no_cycles(among: components.keys)
245
249
  end
246
250
 
247
251
  def cqrs(dsl, commands:, queries:, read_models: nil, mutating_methods: MUTATING_METHODS)
@@ -252,7 +256,7 @@ module ArchSpec
252
256
  proxy_for(dsl, :commands).cannot_use :queries
253
257
  proxy_for(dsl, :queries).cannot_use :commands
254
258
  proxy_for(dsl, :queries).cannot_call(*mutating_methods)
255
- dsl.no_cycles!(among: components.keys)
259
+ dsl.no_cycles(among: components.keys)
256
260
  end
257
261
 
258
262
  def event_driven(dsl, events:, publishers:, subscribers:)
@@ -260,13 +264,36 @@ module ArchSpec
260
264
  define_components(dsl, roles)
261
265
 
262
266
  proxy_for(dsl, :events).cannot_use :publishers, :subscribers
263
- proxy_for(dsl, :publishers).can_use :events
264
- proxy_for(dsl, :subscribers).can_use :events
265
- dsl.no_cycles!(among: roles.keys)
267
+ proxy_for(dsl, :publishers).can_only_use :events
268
+ proxy_for(dsl, :subscribers).can_only_use :events
269
+ dsl.no_cycles(among: roles.keys)
270
+ end
271
+
272
+ # Applies the generic Ruby naming idioms project-wide: no +get_+/+set_+
273
+ # accessors and no +is_+ predicate prefix. Adds no components, so it composes
274
+ # with any other architecture. Project-specific conventions (the +with_x+ /
275
+ # +without_x+ pairing, the +supports_*?+ ban) stay opt-in through the
276
+ # +method_names.matching(...)+ primitives.
277
+ def ruby_conventions(dsl)
278
+ %i[instance class].each do |scope|
279
+ forbid_name(dsl, /\A(get|set)_/, 'use attr_ readers and writers or plain names, not get_/set_', scope: scope)
280
+ forbid_name(dsl, /\Ais_/, 'name predicates with a trailing ? and no is_ prefix (has_ is fine)', scope: scope)
281
+ end
266
282
  end
267
283
 
268
284
  private
269
285
 
286
+ def forbid_name(dsl, regex, reason, scope:)
287
+ dsl.rule(
288
+ Rules::NamingRule.new(
289
+ source: nil,
290
+ selector: Rules::Naming::NameSelector.new(regex),
291
+ constraint: Rules::Naming::Forbidden.new(because: reason),
292
+ scope: scope
293
+ )
294
+ )
295
+ end
296
+
270
297
  def with_defaults(defaults, options)
271
298
  defaults.merge(options)
272
299
  end
@@ -302,6 +329,8 @@ module ArchSpec
302
329
  return unless pattern
303
330
 
304
331
  dsl.component(:concerns, in: pattern).cannot_reference_includers
332
+ # Controllers carry an allowlist, so let them include concerns too.
333
+ proxy_for(dsl, :controllers).can_only_use(:concerns)
305
334
  end
306
335
  end
307
336
  end
data/lib/archspec/cli.rb CHANGED
@@ -107,7 +107,7 @@ module ArchSpec
107
107
 
108
108
  definition, root = load_definition(options[:config])
109
109
  graph = Analyzer.analyze(definition, root: root)
110
- explain_subject(output, graph, subject)
110
+ Formatters::Explanation.print(output, graph: graph, subject: subject)
111
111
  0
112
112
  end
113
113
 
@@ -155,78 +155,6 @@ module ArchSpec
155
155
  end
156
156
  end
157
157
 
158
- def explain_subject(output, graph, subject)
159
- path = File.expand_path(subject, graph.root)
160
-
161
- if graph.files.key?(path)
162
- file = graph.files.fetch(path)
163
- output.puts file.relative_path
164
- output.puts " expected constant: #{file.expected_constant || '(none)'}"
165
- output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(', ')}"
166
- output_parse_errors(output, file)
167
- output_component_reasons(output, graph.component_assignment_reasons_for_path(path))
168
- output_suppressions(output, file)
169
- output.puts ' outgoing facts:'
170
-
171
- graph.edges.select { |edge| edge.from_path == path }.each do |edge|
172
- output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
173
- end
174
- else
175
- constants = graph.constants_named(subject)
176
- raise Error, "No file or constant found for #{subject.inspect}" if constants.empty?
177
-
178
- constants.each do |constant|
179
- output.puts constant.name
180
- output.puts " kind: #{constant.kind}"
181
- output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
182
- output_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
183
- output.puts " superclass: #{constant.superclass || '(none)'}"
184
- output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(', ')}"
185
- output.puts " class methods: #{constant.class_methods.to_a.sort.join(', ')}"
186
- end
187
- end
188
- end
189
-
190
- def output_component_reasons(output, assignments)
191
- if assignments.empty?
192
- output.puts ' components: (none)'
193
- return
194
- end
195
-
196
- output.puts ' components:'
197
- assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
198
- output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
199
- end
200
- end
201
-
202
- def output_suppressions(output, file)
203
- return if file.suppressions.empty?
204
-
205
- output.puts ' suppressions:'
206
- file.suppressions.each do |suppression|
207
- line_range =
208
- if suppression.end_line == Float::INFINITY
209
- "#{suppression.start_line}-EOF"
210
- elsif suppression.start_line == suppression.end_line
211
- suppression.start_line
212
- else
213
- "#{suppression.start_line}-#{suppression.end_line}"
214
- end
215
- rule = suppression.rule || '*'
216
- reason = suppression.reason ? " -- #{suppression.reason}" : ''
217
- output.puts " #{rule} on line #{line_range}#{reason}"
218
- end
219
- end
220
-
221
- def output_parse_errors(output, file)
222
- return if file.parse_errors.empty?
223
-
224
- output.puts ' parse errors:'
225
- file.parse_errors.each do |parse_error|
226
- output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
227
- end
228
- end
229
-
230
158
  def usage
231
159
  <<~TEXT
232
160
  Usage:
@@ -22,7 +22,7 @@ module ArchSpec
22
22
  ].freeze
23
23
 
24
24
  attr_accessor :name, :root_path, :todo_path, :base_dir
25
- attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules, :inflections
25
+ attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
26
26
 
27
27
  def initialize(name = nil)
28
28
  @name = name
@@ -33,11 +33,6 @@ module ArchSpec
33
33
  @ignore_patterns = DEFAULT_IGNORE_PATTERNS.dup
34
34
  @component_specs = {}
35
35
  @rules = []
36
- @inflections = {}
37
- end
38
-
39
- def add_inflections(map)
40
- @inflections.merge!(map.to_h.transform_keys(&:to_s).transform_values(&:to_s))
41
36
  end
42
37
 
43
38
  def add_source_patterns(patterns)