archspec 0.3.0 → 0.4.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: 583efdae4f510098da5807cb0d7f509892851f5f26766e705dd6354c1e6b1748
4
- data.tar.gz: 5523396837663f507b4b474bcc1fd2a803f59f60910047b1dc9e910b1b46fde5
3
+ metadata.gz: 25ea714cd768976e860df4f0dbaa9ff477e381096f95b98787a0a645fc0cd776
4
+ data.tar.gz: 527621e2bb18514433cf26ede594f2cafa2699d0d95e2d3fa4d9e8b9e4aac354
5
5
  SHA512:
6
- metadata.gz: 4761e204b83d63afcaa16c823aa47ba6c39653990d30c30dadd94e0698ffc0dc8566cb0740125c9948c630ab983183b5907828691a55d36a66f314df5f35ea8b
7
- data.tar.gz: d5e8219c53e25cf060e78d74f8e23c75d7fc6204451ee1faf4a7f1ecac8caf53ce02b27d13bb194ca83810d7874e720385ec9daa31508d6840f9bb068c00ba88
6
+ metadata.gz: f9c83e97692a44bbf72391593e695bc4c8a7859d148508f4cb8cf490a55e51a8de98d0df8ab0d37bf8bf13fcdb3e4707f9325850a39b9a33ea678a0d0aa7f3b2
7
+ data.tar.gz: ec19d8b85144cee92cd7bd2c968d848aef39feccdda87b56849dab7ac706543fec8041bee524ab0c5532193b423442f6ef3eec00d6d52f6301ec3e960b5cf1b8
data/README.md CHANGED
@@ -67,7 +67,7 @@ Keep a hexagonal core away from adapters:
67
67
  architecture :hexagonal
68
68
  ```
69
69
 
70
- Check a modular monolith:
70
+ Check a modular monolith, and make packs go through a public API:
71
71
 
72
72
  ```ruby
73
73
  architecture :modular_monolith,
@@ -79,6 +79,9 @@ architecture :modular_monolith,
79
79
  allow: {
80
80
  billing: %i[shared],
81
81
  catalog: %i[shared]
82
+ },
83
+ public: {
84
+ billing: "packs/billing/app/public/**/*.rb"
82
85
  }
83
86
  ```
84
87
 
@@ -106,7 +109,9 @@ architecture :cqrs,
106
109
 
107
110
  ## What It Checks
108
111
 
109
- - **Dependencies:** allowed and forbidden references between components
112
+ - **Dependencies:** allowed and forbidden references between components, in both directions
113
+ - **Privacy:** other components must go through a component's public API
114
+ - **Concerns:** a concern must not depend on the classes that include it
110
115
  - **Layers:** dependency direction and cycles
111
116
  - **Rails:** controller APIs kept out of models and services
112
117
  - **Architectures:** Rails, vanilla Rails, layered, hexagonal, clean, modular monolith, CQRS, and event-driven bundles
@@ -150,7 +155,7 @@ bundle exec archspec check
150
155
  bundle exec archspec init
151
156
  bundle exec archspec check
152
157
  bundle exec archspec check --format json
153
- bundle exec archspec check --update-baseline
158
+ bundle exec archspec check --update-todo
154
159
  bundle exec archspec explain app/models/user.rb
155
160
  ```
156
161
 
@@ -161,12 +166,15 @@ facts ArchSpec found.
161
166
 
162
167
  Generated code should pass the same architecture checks as hand-written code.
163
168
 
164
- After an AI-assisted change:
169
+ After an AI-assisted change, check the whole project, or just the files that changed:
165
170
 
166
171
  ```sh
167
172
  bundle exec archspec check
173
+ bundle exec archspec check app/models/user.rb app/services
168
174
  ```
169
175
 
176
+ Passing paths still analyzes the project so dependencies resolve, but reports only violations in those paths. This keeps the loop tight after each edit.
177
+
170
178
  If it fails, read the evidence before changing the spec:
171
179
 
172
180
  ```text
@@ -179,16 +187,17 @@ If it fails, read the evidence before changing the spec:
179
187
  Most failures should be fixed in the generated code. Update the spec only when
180
188
  the architecture decision itself has changed.
181
189
 
182
- ## Baselines and Suppressions
190
+ ## Todo and Suppressions
183
191
 
184
- Use a baseline when adopting ArchSpec in an existing app:
192
+ Use a todo file when adopting ArchSpec in an existing app. It records the current
193
+ violations so they stop failing the build, leaving a list to burn down:
185
194
 
186
195
  ```ruby
187
- baseline ".archspec_todo.yml"
196
+ todo "archspec_todo.yml"
188
197
  ```
189
198
 
190
199
  ```sh
191
- bundle exec archspec check --update-baseline
200
+ bundle exec archspec check --update-todo
192
201
  ```
193
202
 
194
203
  Use local suppressions for deliberate exceptions:
@@ -210,6 +219,12 @@ This repository checks its own architecture:
210
219
  bundle exec rake architecture
211
220
  ```
212
221
 
222
+ It also runs against pinned checkouts of large real-world Rails apps to catch crashes and false positives before they ship:
223
+
224
+ ```sh
225
+ bundle exec rake torture
226
+ ```
227
+
213
228
  ## License
214
229
 
215
230
  Released under the MIT License.
@@ -15,7 +15,7 @@ module ArchSpec
15
15
  result = Prism.parse_file(path)
16
16
  graph.add_file(
17
17
  path: path,
18
- expected_constant: expected_constant_for(path, root),
18
+ expected_constant: expected_constant_for(path, root, definition.inflections),
19
19
  parse_errors: parse_errors_for(path, result.errors),
20
20
  suppressions: suppressions_for(result.comments)
21
21
  )
@@ -49,7 +49,7 @@ module ArchSpec
49
49
  end.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }.to_set
50
50
  end
51
51
 
52
- def expected_constant_for(path, root)
52
+ def expected_constant_for(path, root, inflections = {})
53
53
  relative = Pathname(path).relative_path_from(Pathname(root)).to_s
54
54
  stem =
55
55
  case relative
@@ -67,12 +67,17 @@ module ArchSpec
67
67
  return nil
68
68
  end
69
69
 
70
- camelize_path(stem)
70
+ camelize_path(stem, inflections)
71
71
  end
72
72
 
73
- def camelize_path(path)
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 = {})
74
77
  path.split('/').map do |part|
75
- part.split('_').map { |word| word[0] ? word[0].upcase + word[1..] : word }.join
78
+ inflections[part] || part.split('_').map do |word|
79
+ inflections[word] || (word[0] ? word[0].upcase + word[1..] : word)
80
+ end.join
76
81
  end.join('::')
77
82
  end
78
83
 
@@ -164,6 +169,12 @@ module ArchSpec
164
169
  extend: :extends
165
170
  }.freeze
166
171
 
172
+ ATTR_MESSAGES = {
173
+ attr_reader: %i[reader],
174
+ attr_writer: %i[writer],
175
+ attr_accessor: %i[reader writer]
176
+ }.freeze
177
+
167
178
  def visit(graph, path, node, current_constant: nil, namespace: [])
168
179
  return unless node
169
180
 
@@ -197,15 +208,25 @@ module ArchSpec
197
208
  )
198
209
 
199
210
  if node.superclass
200
- superclass = constant_reference_name(node.superclass)
201
- constant.superclass = superclass
202
- graph.add_edge(
203
- type: :inherits_from,
204
- from_path: path,
205
- from_constant: constant.name,
206
- to: superclass,
207
- location: SourceLocation.from_prism(path, node.superclass.location)
208
- )
211
+ superclass = constant_reference_name(node.superclass) if constant_node?(node.superclass)
212
+
213
+ if superclass
214
+ constant.superclass = superclass
215
+ graph.add_edge(
216
+ type: :inherits_from,
217
+ from_path: path,
218
+ from_constant: constant.name,
219
+ to: superclass,
220
+ location: SourceLocation.from_prism(path, node.superclass.location)
221
+ )
222
+ else
223
+ # Dynamic superclass (Struct.new, DelegateClass(...)): no
224
+ # inherits_from edge, but constants inside still count as
225
+ # references, and ancestry stays marked unresolved.
226
+ constant.superclass = node.superclass.slice
227
+ visit(graph, path, node.superclass, current_constant: constant.name,
228
+ namespace: constant.name.split('::'))
229
+ end
209
230
  end
210
231
 
211
232
  visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
@@ -277,6 +298,8 @@ module ArchSpec
277
298
  )
278
299
  end
279
300
 
301
+ record_generated_methods(graph, path, node, message, current_constant, location)
302
+
280
303
  if (edge_type = MIXIN_MESSAGES[message])
281
304
  constant_arguments(node).each do |constant_name|
282
305
  if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
@@ -300,7 +323,8 @@ module ArchSpec
300
323
  from_path: path,
301
324
  from_constant: current_constant,
302
325
  to: message,
303
- location: location
326
+ location: location,
327
+ receiver: receiver_kind(node)
304
328
  )
305
329
 
306
330
  if DYNAMIC_MESSAGES.include?(message)
@@ -318,11 +342,14 @@ module ArchSpec
318
342
  end
319
343
 
320
344
  def add_constant_reference(graph, path, node, current_constant)
345
+ name = constant_reference_name(node)
346
+ return unless name
347
+
321
348
  graph.add_edge(
322
349
  type: :references_constant,
323
350
  from_path: path,
324
351
  from_constant: current_constant,
325
- to: constant_reference_name(node),
352
+ to: name,
326
353
  location: SourceLocation.from_prism(path, node.location)
327
354
  )
328
355
  end
@@ -349,27 +376,76 @@ module ArchSpec
349
376
  end || []
350
377
  end
351
378
 
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)
382
+ return unless current_constant && node.receiver.nil?
383
+ return unless ATTR_MESSAGES.key?(message) || message == :delegate
384
+
385
+ constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path }
386
+ return unless constant
387
+
388
+ names = symbol_arguments(node)
389
+ return if message == :delegate && keyword_argument?(node, :prefix)
390
+
391
+ 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)
395
+ end
396
+ end
397
+
398
+ def symbol_arguments(node)
399
+ node.arguments&.arguments&.filter_map do |argument|
400
+ argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
401
+ end || []
402
+ end
403
+
404
+ def keyword_argument?(node, name)
405
+ node.arguments&.arguments&.any? do |argument|
406
+ argument.is_a?(Prism::KeywordHashNode) && argument.elements.any? do |element|
407
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
408
+ element.key.unescaped.to_sym == name
409
+ end
410
+ end
411
+ end
412
+
413
+ def receiver_kind(node)
414
+ receiver = node.receiver
415
+ return :none if receiver.nil? || receiver.is_a?(Prism::SelfNode)
416
+ return :constant if constant_node?(receiver)
417
+
418
+ :other
419
+ end
420
+
352
421
  def instantiates_and_invokes(node)
353
422
  receiver = node.receiver
354
423
  return unless receiver.is_a?(Prism::CallNode) && receiver.message&.to_sym == :new
355
424
 
356
- name = constant_node?(receiver.receiver) ? constant_reference_name(receiver.receiver) : receiver.receiver&.slice
425
+ receiver_node = receiver.receiver
426
+ name = (constant_reference_name(receiver_node) if constant_node?(receiver_node)) || receiver_node&.slice
357
427
  "#{name}##{node.message}"
358
428
  end
359
429
 
430
+ # class Users::RolesController inside module Admin defines
431
+ # Admin::Users::RolesController, so compact paths join the namespace too.
360
432
  def qualified_constant_name(node, namespace)
361
433
  raw = constant_reference_name(node)
362
434
  absolute = node.respond_to?(:full_name_parts) && node.full_name_parts.first == :""
363
435
 
364
- if absolute || raw.include?('::') || namespace.empty?
436
+ if absolute || namespace.empty?
365
437
  raw
366
438
  else
367
439
  "#{namespace.join('::')}::#{raw}"
368
440
  end
369
441
  end
370
442
 
443
+ # nil when the path has dynamic parts (self.class::FOO): there is no
444
+ # static name to check against.
371
445
  def constant_reference_name(node)
372
446
  node.full_name.to_s.sub(/\A::/, '')
447
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
448
+ nil
373
449
  end
374
450
 
375
451
  def constant_node?(node)
@@ -1,6 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
+ # Bundled architecture presets. Each applies a set of components and rules in
5
+ # one call, invoked from the DSL through
6
+ # ArchSpec::DSL::Context#architecture:
7
+ #
8
+ # architecture :rails
9
+ # architecture :layered, layers: { ... }
10
+ #
11
+ # Every preset accepts overrides for its directories, so you can keep the
12
+ # shape while pointing at your own paths. The presets are:
13
+ #
14
+ # +:rails+ (aliases +:rails_mvc+, +:rails_way+):: Conventional MVC. Keeps
15
+ # controller APIs out of models and services. Options: +components:+,
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:+,
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:+,
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:+.
38
+ #
39
+ # See the guides at https://archspecrb.dev/architectures/ for each in depth.
4
40
  module Architectures
5
41
  extend self
6
42
 
@@ -54,6 +90,8 @@ module ArchSpec
54
90
  view_components: ['app/components/**/*.rb', 'use helpers and ERB partials']
55
91
  }.freeze
56
92
 
93
+ DEFAULT_CONCERNS = 'app/**/concerns/**/*.rb'
94
+
57
95
  CONTROLLER_METHODS = %i[render redirect_to params session cookies flash].freeze
58
96
  MUTATING_METHODS = %i[
59
97
  create create!
@@ -65,17 +103,34 @@ module ArchSpec
65
103
  upsert upsert!
66
104
  ].freeze
67
105
 
106
+ # Applies the named preset to +dsl+, forwarding +options+ to it. Raises
107
+ # ArchSpec::Error for an unknown name. Called by
108
+ # ArchSpec::DSL::Context#architecture, so you rarely call it directly.
68
109
  def apply(name, dsl, **options)
69
110
  case name.to_sym
70
111
  when :rails, :rails_mvc, :rails_way
71
- rails_mvc(dsl, components: options.fetch(:components, DEFAULT_RAILS_MVC))
112
+ rails_mvc(
113
+ dsl,
114
+ components: options.fetch(:components, DEFAULT_RAILS_MVC),
115
+ controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
116
+ share_helpers: options.fetch(:share_helpers, false)
117
+ )
72
118
  when :rails_strict
73
- rails_strict(dsl, components: options.fetch(:components, DEFAULT_RAILS_MVC))
119
+ rails_strict(
120
+ dsl,
121
+ components: options.fetch(:components, DEFAULT_RAILS_MVC),
122
+ controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
123
+ share_helpers: options.fetch(:share_helpers, false),
124
+ concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
125
+ )
74
126
  when :vanilla_rails
75
127
  vanilla_rails(
76
128
  dsl,
77
129
  components: options.fetch(:components, DEFAULT_RAILS_MVC),
78
- empty: options.fetch(:empty, VANILLA_RAILS_EMPTY)
130
+ empty: options.fetch(:empty, VANILLA_RAILS_EMPTY),
131
+ controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
132
+ share_helpers: options.fetch(:share_helpers, false),
133
+ concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
79
134
  )
80
135
  when :layered, :rails_layered
81
136
  layered(dsl, layers: options.fetch(:layers, DEFAULT_LAYERED))
@@ -84,7 +139,12 @@ module ArchSpec
84
139
  when :clean, :rails_clean
85
140
  clean(dsl, **with_defaults(DEFAULT_CLEAN, options))
86
141
  when :modular_monolith, :bounded_contexts
87
- modular_monolith(dsl, components: options.fetch(:components), allow: options.fetch(:allow, {}))
142
+ modular_monolith(
143
+ dsl,
144
+ components: options.fetch(:components),
145
+ allow: options.fetch(:allow, {}),
146
+ public: options.fetch(:public, {})
147
+ )
88
148
  when :cqrs, :rails_cqrs
89
149
  cqrs(dsl, **with_defaults(DEFAULT_CQRS, options))
90
150
  when :event_driven, :rails_event_driven
@@ -94,30 +154,38 @@ module ArchSpec
94
154
  end
95
155
  end
96
156
 
97
- def rails_mvc(dsl, components:)
157
+ def rails_mvc(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false)
98
158
  components = normalize_map(components)
99
159
  define_components(dsl, components)
100
160
 
161
+ forbidden = share_helpers ? %i[controllers] : %i[controllers helpers]
101
162
  proxy_for(dsl, :controllers).can_use(*components.keys & %i[models services helpers mailers jobs])
102
- proxy_for(dsl, :models).cannot_use(*components.keys & %i[controllers helpers])
103
- proxy_for(dsl, :services).cannot_use(*components.keys & %i[controllers helpers])
104
- proxy_for(dsl, :models).cannot_call(*CONTROLLER_METHODS)
105
- proxy_for(dsl, :services).cannot_call(*CONTROLLER_METHODS)
163
+ proxy_for(dsl, :models).cannot_use(*components.keys & forbidden)
164
+ proxy_for(dsl, :services).cannot_use(*components.keys & forbidden)
165
+
166
+ return if controller_api.empty?
167
+
168
+ proxy_for(dsl, :models).cannot_call(*controller_api, receiver: :none)
169
+ proxy_for(dsl, :services).cannot_call(*controller_api, receiver: :none)
106
170
  end
107
171
 
108
- def rails_strict(dsl, components:)
172
+ def rails_strict(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false, concerns: DEFAULT_CONCERNS)
109
173
  components = normalize_map(components)
110
- rails_mvc(dsl, components: components)
174
+ rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
111
175
  dsl.verify_zeitwerk_names!
112
176
  dsl.no_cycles!(among: components.keys)
177
+ independent_concerns(dsl, concerns)
113
178
  end
114
179
 
115
- def vanilla_rails(dsl, components:, empty:)
116
- rails_mvc(dsl, components: components)
180
+ def vanilla_rails(dsl, components:, empty:, controller_api: CONTROLLER_METHODS, share_helpers: false,
181
+ concerns: DEFAULT_CONCERNS)
182
+ rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
117
183
 
118
184
  empty.each do |name, (pattern, reason)|
119
185
  dsl.component(name, in: pattern).must_be_empty(because: reason)
120
186
  end
187
+
188
+ independent_concerns(dsl, concerns)
121
189
  end
122
190
 
123
191
  def layered(dsl, layers:)
@@ -161,13 +229,16 @@ module ArchSpec
161
229
  )
162
230
  end
163
231
 
164
- def modular_monolith(dsl, components:, allow: {})
232
+ def modular_monolith(dsl, components:, allow: {}, public: {})
165
233
  components = normalize_map(components)
166
234
  define_components(dsl, components)
167
235
 
168
236
  components.each_key do |name|
169
237
  allowed = Array(allow[name] || allow[name.to_s])
170
238
  proxy_for(dsl, name).can_use(*allowed)
239
+
240
+ patterns = Array(public[name] || public[name.to_s])
241
+ proxy_for(dsl, name).public_api(*patterns) if patterns.any?
171
242
  end
172
243
 
173
244
  dsl.no_cycles!(among: components.keys)
@@ -226,5 +297,11 @@ module ArchSpec
226
297
  def proxy_for(dsl, name)
227
298
  DSL::ComponentProxy.new(dsl, name)
228
299
  end
300
+
301
+ def independent_concerns(dsl, pattern)
302
+ return unless pattern
303
+
304
+ dsl.component(:concerns, in: pattern).cannot_reference_includers
305
+ end
229
306
  end
230
307
  end
data/lib/archspec/cli.rb CHANGED
@@ -3,6 +3,15 @@
3
3
  require 'optparse'
4
4
 
5
5
  module ArchSpec
6
+ # The <tt>archspec</tt> command line. Backs the +exe/archspec+ executable and
7
+ # dispatches the +init+, +check+, +explain+, and +version+ subcommands.
8
+ #
9
+ # archspec init
10
+ # archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
11
+ # archspec explain PATH_OR_CONSTANT
12
+ #
13
+ # #run returns the process exit status: 0 when clean, 1 when violations are
14
+ # found.
6
15
  module CLI
7
16
  extend self
8
17
 
@@ -52,30 +61,33 @@ module ArchSpec
52
61
  options = {
53
62
  config: CONFIG_FILE,
54
63
  format: 'text',
55
- update_baseline: false
64
+ update_todo: false
56
65
  }
57
66
 
58
67
  parser = OptionParser.new do |opts|
59
68
  opts.on('--config PATH') { |value| options[:config] = value }
60
69
  opts.on('--format FORMAT') { |value| options[:format] = value }
61
- opts.on('--update-baseline') { options[:update_baseline] = true }
70
+ opts.on('--update-todo') { options[:update_todo] = true }
62
71
  end
63
72
  parser.parse!(argv)
64
73
 
74
+ raise Error, 'Cannot combine --update-todo with path arguments.' if options[:update_todo] && argv.any?
75
+
65
76
  definition, root = load_definition(options[:config])
66
77
  graph = Analyzer.analyze(definition, root: root)
67
- baseline_path = baseline_path_for(definition, root)
68
- baseline = options[:update_baseline] ? Baseline.empty(root: root) : Baseline.load(baseline_path, root: root)
69
- diagnostics = Evaluator.evaluate(definition, graph, baseline: baseline)
78
+ todo_path = todo_path_for(definition, root)
79
+ todo = options[:update_todo] ? Todo.empty(root: root) : Todo.load(todo_path, root: root)
80
+ diagnostics = Evaluator.evaluate(definition, graph, todo: todo)
81
+ diagnostics = scope_to_paths(diagnostics, argv, root)
70
82
 
71
- if options[:update_baseline]
72
- unless baseline_path
83
+ if options[:update_todo]
84
+ unless todo_path
73
85
  raise Error,
74
- "No baseline configured. Add `baseline \".archspec_todo.yml\"` to #{options[:config]}."
86
+ "No todo configured. Add `todo \"archspec_todo.yml\"` to #{options[:config]}."
75
87
  end
76
88
 
77
- Baseline.write(baseline_path, diagnostics, root: root)
78
- output.puts "Updated #{Pathname(baseline_path).relative_path_from(Pathname(root))} with #{diagnostics.size} violations."
89
+ Todo.write(todo_path, diagnostics, root: root)
90
+ output.puts "Updated #{Pathname(todo_path).relative_path_from(Pathname(root))} with #{diagnostics.size} violations."
79
91
  return 0
80
92
  end
81
93
 
@@ -104,18 +116,32 @@ module ArchSpec
104
116
 
105
117
  ArchSpec.last_definition = nil
106
118
  absolute_config = File.expand_path(config_path)
119
+ config_dir = File.dirname(absolute_config)
107
120
  definition = Definition.new
121
+ definition.base_dir = config_dir
108
122
  definition.extend(DSL::Context)
109
123
  definition.instance_eval(File.read(absolute_config), absolute_config)
110
124
  definition = ArchSpec.last_definition || definition
125
+ definition.base_dir ||= config_dir
126
+
127
+ [definition, definition.absolute_root(config_dir)]
128
+ end
111
129
 
112
- [definition, definition.absolute_root(File.dirname(absolute_config))]
130
+ def scope_to_paths(diagnostics, paths, root)
131
+ return diagnostics if paths.empty?
132
+
133
+ expanded = paths.map { |path| File.expand_path(path, root) }
134
+ diagnostics.select do |diagnostic|
135
+ expanded.any? do |path|
136
+ diagnostic.location.path == path || diagnostic.location.path.start_with?("#{path}/")
137
+ end
138
+ end
113
139
  end
114
140
 
115
- def baseline_path_for(definition, root)
116
- return unless definition.baseline_path
141
+ def todo_path_for(definition, root)
142
+ return unless definition.todo_path
117
143
 
118
- File.expand_path(definition.baseline_path, root)
144
+ File.expand_path(definition.todo_path, root)
119
145
  end
120
146
 
121
147
  def formatter_for(name)
@@ -205,7 +231,7 @@ module ArchSpec
205
231
  <<~TEXT
206
232
  Usage:
207
233
  archspec init [PATH] [--force]
208
- archspec check [--config PATH] [--format text|json] [--update-baseline]
234
+ archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
209
235
  archspec explain PATH_OR_CONSTANT [--config PATH]
210
236
  archspec version
211
237
  TEXT
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
+ # How a component selects its members: by file glob, by namespace, or by
5
+ # explicit constant name. Created by ArchSpec::DSL::Context#component. The
6
+ # analyzer uses it to assign files and constants to the component.
4
7
  class ComponentSpec
5
8
  attr_reader :name, :file_patterns, :namespaces, :constants
6
9
 
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
+ # The result of evaluating an +Archspec.rb+ file: the project settings,
5
+ # declared components, and rules. ArchSpec::DSL::Context is mixed into an
6
+ # instance to provide the DSL, and the analyzer and evaluator read it to run
7
+ # the checks. Build one with ArchSpec.define.
4
8
  class Definition
5
9
  DEFAULT_SOURCE_PATTERNS = [
6
10
  'app/**/*.rb',
@@ -17,17 +21,23 @@ module ArchSpec
17
21
  'vendor/**/*'
18
22
  ].freeze
19
23
 
20
- attr_accessor :name, :root_path, :baseline_path
21
- attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
24
+ attr_accessor :name, :root_path, :todo_path, :base_dir
25
+ attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules, :inflections
22
26
 
23
27
  def initialize(name = nil)
24
28
  @name = name
25
29
  @root_path = '.'
26
- @baseline_path = nil
30
+ @todo_path = nil
31
+ @base_dir = nil
27
32
  @source_patterns = []
28
33
  @ignore_patterns = DEFAULT_IGNORE_PATTERNS.dup
29
34
  @component_specs = {}
30
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))
31
41
  end
32
42
 
33
43
  def add_source_patterns(patterns)
@@ -54,8 +64,11 @@ module ArchSpec
54
64
  rules << rule
55
65
  end
56
66
 
57
- def absolute_root(base_dir = Dir.pwd)
58
- File.expand_path(root_path, base_dir)
67
+ # The directory file patterns resolve against: root_path expanded from the
68
+ # directory the Archspec.rb was loaded from (base_dir), or the working
69
+ # directory when built without a file.
70
+ def absolute_root(base = base_dir || Dir.pwd)
71
+ File.expand_path(root_path, base)
59
72
  end
60
73
 
61
74
  def analysis_patterns
@@ -3,6 +3,10 @@
3
3
  require 'digest'
4
4
 
5
5
  module ArchSpec
6
+ # One reported violation: the rule id, a message, the source location, the
7
+ # evidence ArchSpec found, and a confidence. Formatters and the todo file read
8
+ # these. Its #fingerprint is the stable id used to match todo entries and
9
+ # suppress specific findings.
6
10
  class Diagnostic
7
11
  attr_reader :rule, :message, :location, :evidence, :confidence
8
12
 
@@ -14,11 +18,13 @@ module ArchSpec
14
18
  @confidence = confidence
15
19
  end
16
20
 
21
+ # Line numbers stay out of the fingerprint so todo entries survive edits
22
+ # that only shift code around.
17
23
  def fingerprint(root: nil)
18
24
  path = root ? location.relative_path(root) : location.path
19
25
 
20
26
  Digest::SHA256.hexdigest(
21
- [rule, message, path, location.line, evidence].join("\0")
27
+ [rule, message, path, evidence].join("\0")
22
28
  )[0, 24]
23
29
  end
24
30