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.
data/lib/archspec/dsl.rb CHANGED
@@ -1,26 +1,105 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pathname'
4
+
3
5
  module ArchSpec
6
+ # The ArchSpec DSL.
7
+ #
8
+ # An +Archspec.rb+ file is evaluated in this context, so every method here is
9
+ # a top-level call in that file.
4
10
  module DSL
11
+ # The top-level DSL. Declare the project, its components, an architecture
12
+ # preset, and global rules.
13
+ #
14
+ # root "."
15
+ # source "app/**/*.rb", "lib/**/*.rb"
16
+ # ignore "app/legacy/**/*.rb"
17
+ # todo "archspec_todo.yml"
18
+ #
19
+ # component :models, in: "app/models/**/*.rb"
20
+ # component :controllers, in: "app/controllers/**/*.rb"
21
+ # models.cannot_use :controllers
22
+ #
23
+ # Declaring a component defines a reader for it, so +models+ and
24
+ # +controllers+ above return an ArchSpec::DSL::ComponentProxy you attach
25
+ # rules to.
5
26
  module Context
27
+ # Sets or reads the project root that file patterns resolve against.
28
+ # Defaults to the directory of the +Archspec.rb+ file.
6
29
  def root(path = nil)
7
30
  return root_path unless path
8
31
 
9
32
  self.root_path = path.to_s
10
33
  end
11
34
 
35
+ # Adds glob patterns for the files ArchSpec parses. Defaults cover
36
+ # +app+, +lib+, packs, and engines. Component patterns are always
37
+ # analyzed, so most projects never need this.
12
38
  def source(*patterns)
13
39
  add_source_patterns(patterns)
14
40
  end
15
41
 
42
+ # Adds glob patterns for files to skip. Combines with the built-in
43
+ # ignores for +.git+, +tmp+, +vendor+, and +node_modules+.
16
44
  def ignore(*patterns)
17
45
  add_ignore_patterns(patterns)
18
46
  end
19
47
 
20
- def baseline(path = '.archspec_todo.yml')
21
- self.baseline_path = path.to_s
48
+ # Points at a todo file of accepted violations. Diagnostics recorded there
49
+ # are subtracted from future runs, so you can adopt ArchSpec in an existing
50
+ # app without fixing everything first, then burn the list down.
51
+ #
52
+ # todo "archspec_todo.yml"
53
+ #
54
+ # Write or refresh it with <tt>archspec check --update-todo</tt>.
55
+ def todo(path = 'archspec_todo.yml')
56
+ self.todo_path = path.to_s
57
+ end
58
+
59
+ # Declares acronym inflections for Zeitwerk name checks, mirroring your
60
+ # +config/initializers/inflections.rb+.
61
+ #
62
+ # inflect "api" => "API", "graphql" => "GraphQL"
63
+ #
64
+ # +app/models/api_client.rb+ then expects +APIClient+.
65
+ def inflect(map)
66
+ add_inflections(map)
22
67
  end
23
68
 
69
+ # Yields each subdirectory matching a glob, so you can declare one
70
+ # component per engine or pack without hardcoding their names. Paths
71
+ # resolve against the +Archspec.rb+ directory, not the working directory,
72
+ # so it does not matter where +archspec+ is run from.
73
+ #
74
+ # each_directory "engines/*" do |name, path|
75
+ # component name, in: "#{path}/**/*.rb"
76
+ # end
77
+ #
78
+ # Yields the directory basename and its root-relative path. Returns the
79
+ # <tt>[name, path]</tt> pairs when called without a block.
80
+ def each_directory(glob)
81
+ base = absolute_root
82
+ pairs = Dir.glob(File.join(base, glob)).select { |path| File.directory?(path) }.sort.map do |absolute|
83
+ [File.basename(absolute), Pathname(absolute).relative_path_from(Pathname(base)).to_s]
84
+ end
85
+
86
+ return pairs unless block_given?
87
+
88
+ pairs.each { |name, path| yield(name, path) }
89
+ end
90
+
91
+ # Declares a component: a named set of files, matched by glob, namespace,
92
+ # or explicit constant.
93
+ #
94
+ # component :services, in: "app/services/**/*.rb"
95
+ # component :billing, namespace: "Billing"
96
+ # component :legacy, constants: %w[OldReport OldExport]
97
+ #
98
+ # Returns an ArchSpec::DSL::ComponentProxy for attaching rules. The
99
+ # component is also available by name later in the file.
100
+ #
101
+ # +layer+ and +role+ are aliases. Use whichever word fits the
102
+ # architecture you are describing.
24
103
  def component(name, in: nil, namespace: nil, constants: nil)
25
104
  add_component(
26
105
  ComponentSpec.new(name, files: binding.local_variable_get(:in), namespace: namespace, constants: constants)
@@ -31,18 +110,44 @@ module ArchSpec
31
110
  alias layer component
32
111
  alias role component
33
112
 
113
+ # Applies a bundled architecture preset, defining its components and
114
+ # rules together.
115
+ #
116
+ # architecture :rails
117
+ # architecture :hexagonal
118
+ # architecture :modular_monolith, components: { ... }, allow: { ... }
119
+ #
120
+ # See ArchSpec::Architectures for every preset and its options.
34
121
  def architecture(name, **options)
35
122
  Architectures.apply(name, self, **options)
36
123
  end
37
124
 
125
+ # Forbids dependency cycles between components. Pass +among:+ to limit the
126
+ # check to a subset; omit it to check every declared component.
127
+ #
128
+ # no_cycles!
129
+ # no_cycles! among: %i[billing catalog shared]
130
+ #
131
+ # Rule id: +dependencies.no_cycles+.
38
132
  def no_cycles!(among: nil)
39
133
  add_rule(Rules::NoCyclesRule.new(among: among))
40
134
  end
41
135
 
42
- def verify_zeitwerk_names!
43
- add_rule(Rules::ZeitwerkNamingRule.new)
136
+ # Checks that files define the constant their path implies under Zeitwerk.
137
+ # Pass globs to restrict the check to the autoloaded tree, since Rails
138
+ # does not autoload +lib+ by default.
139
+ #
140
+ # verify_zeitwerk_names!
141
+ # verify_zeitwerk_names! "app/**/*.rb"
142
+ #
143
+ # Rule id: +zeitwerk.naming+.
144
+ def verify_zeitwerk_names!(*only)
145
+ add_rule(Rules::ZeitwerkNamingRule.new(only: only))
44
146
  end
45
147
 
148
+ # Adds a custom rule object. A rule responds to +id+ and
149
+ # <tt>evaluate(graph)</tt>, returning ArchSpec::Diagnostic objects. Use
150
+ # this to extend ArchSpec with project-specific checks.
46
151
  def rule(rule)
47
152
  add_rule(rule)
48
153
  end
@@ -58,6 +163,11 @@ module ArchSpec
58
163
  end
59
164
  end
60
165
 
166
+ # A handle to one component, returned by ArchSpec::DSL::Context#component
167
+ # and by calling a declared component's name. Rule methods return +self+, so
168
+ # they chain.
169
+ #
170
+ # services.cannot_use(:controllers).cannot_call(:render, receiver: :none)
61
171
  class ComponentProxy
62
172
  attr_reader :definition, :name
63
173
 
@@ -66,6 +176,13 @@ module ArchSpec
66
176
  @name = name.to_sym
67
177
  end
68
178
 
179
+ # Allowlists the components this one may depend on. A reference to any
180
+ # other declared component fails.
181
+ #
182
+ # controllers.can_use :models, :services
183
+ #
184
+ # +only_depend_on+ and +must_only_depend_on+ are aliases.
185
+ # Rule id: +dependencies.allow+.
69
186
  def can_use(*targets)
70
187
  add_rule(Rules::AllowDependenciesRule.new(name, targets))
71
188
  self
@@ -74,36 +191,126 @@ module ArchSpec
74
191
  alias only_depend_on can_use
75
192
  alias must_only_depend_on can_use
76
193
 
194
+ # Forbids depending on the named components. Narrower than #can_use: only
195
+ # the listed components fail, other dependencies are left alone.
196
+ #
197
+ # models.cannot_use :controllers, :helpers
198
+ #
199
+ # Rule id: +dependencies.forbid+.
77
200
  def cannot_use(*targets)
78
201
  add_rule(Rules::ForbidDependenciesRule.new(name, targets))
79
202
  self
80
203
  end
81
204
 
82
- def cannot_call(*methods)
83
- add_rule(Rules::CannotCallRule.new(name, methods))
205
+ # Allowlists the components that may reference this one, the inverse of
206
+ # #can_use. A reference from any other component fails. Use it to protect
207
+ # a shared kernel or a component with a deliberately narrow audience.
208
+ #
209
+ # shared_kernel.can_only_be_used_by :billing, :catalog
210
+ #
211
+ # Rule id: +dependencies.consumers+.
212
+ def can_only_be_used_by(*consumers)
213
+ add_rule(Rules::AllowedConsumersRule.new(name, consumers))
84
214
  self
85
215
  end
86
216
 
217
+ # Forbids calling the named methods. By default any receiver matches, so
218
+ # this catches +record.update+ and +cache.update+ alike. Pass
219
+ # <tt>receiver: :none</tt> to match only bare, implicit-+self+ calls, which
220
+ # is how the Rails presets keep the controller API out of models.
221
+ #
222
+ # queries.cannot_call :save, :update, :destroy
223
+ # services.cannot_call :render, :params, receiver: :none
224
+ #
225
+ # A bare call to a method the component defines, inherits, or generates
226
+ # with +attr_*+ or +delegate+ is treated as its own API and not flagged.
227
+ # Rule id: +methods.forbid+.
228
+ def cannot_call(*methods, receiver: :any)
229
+ add_rule(Rules::CannotCallRule.new(name, methods, receiver: receiver))
230
+ self
231
+ end
232
+
233
+ # Forbids defining the named methods in this component. Use it when the
234
+ # method name itself is a design smell there, such as +call+ on a
235
+ # component that should not hold command objects.
236
+ #
237
+ # models.cannot_define :call
238
+ #
239
+ # Rule id: +methods.define_forbid+.
87
240
  def cannot_define(*methods)
88
241
  add_rule(Rules::CannotDefineMethodRule.new(name, methods))
89
242
  self
90
243
  end
91
244
 
245
+ # Forbids the one-shot <tt>Thing.new(...).call</tt> pattern, where a class
246
+ # is instantiated and immediately invoked. Use it to steer a component
247
+ # toward plain methods over anonymous command objects.
248
+ #
249
+ # Rule id: +objects.instantiate_and_invoke_forbid+.
92
250
  def cannot_instantiate_and_invoke
93
251
  add_rule(Rules::CannotInstantiateAndInvokeRule.new(name))
94
252
  self
95
253
  end
96
254
 
255
+ # Forbids referencing the named constants or anything under them. Use this
256
+ # when the boundary is a framework constant rather than a component.
257
+ #
258
+ # models.cannot_reference_constants "ActionController", "ActionView"
259
+ #
260
+ # Rule id: +constants.forbid+.
97
261
  def cannot_reference_constants(*constants)
98
262
  add_rule(Rules::CannotReferenceConstantsRule.new(name, constants))
99
263
  self
100
264
  end
101
265
 
266
+ # Marks part of the component as its public API. References from outside
267
+ # must resolve to a public constant; everything else becomes private.
268
+ #
269
+ # billing.public_api "packs/billing/app/public/**/*.rb"
270
+ # billing.public_api constants: "Billing::Api"
271
+ # billing.public_api namespace: "Billing::Public"
272
+ #
273
+ # +constants+ matches exact names, +namespace+ matches a name and its
274
+ # children. Code inside the component may still reach its own internals.
275
+ # Rule id: +dependencies.privacy+.
276
+ def public_api(*patterns, constants: nil, namespace: nil)
277
+ add_rule(Rules::PublicApiRule.new(name, files: patterns, constants: constants, namespaces: namespace))
278
+ self
279
+ end
280
+
281
+ # Forbids a concern from referencing the constants that include it. A
282
+ # concern that names its includer knows too much about who uses it, which
283
+ # couples the two and defeats the point of extracting the concern.
284
+ #
285
+ # component :model_concerns, in: "app/models/concerns/**/*.rb"
286
+ # model_concerns.cannot_reference_includers
287
+ #
288
+ # Rule id: +concerns.independence+.
289
+ def cannot_reference_includers
290
+ add_rule(Rules::ConcernIndependenceRule.new(name))
291
+ self
292
+ end
293
+
294
+ # Requires the component to hold no files. Use it to keep a directory
295
+ # empty, such as +app/services+ in a vanilla Rails app, with a reason
296
+ # shown in the diagnostic.
297
+ #
298
+ # component(:services, in: "app/services/**/*.rb")
299
+ # .must_be_empty(because: "behavior belongs on models")
300
+ #
301
+ # Rule id: +components.empty+.
102
302
  def must_be_empty(because: nil)
103
303
  add_rule(Rules::MustBeEmptyRule.new(name, because: because))
104
304
  self
105
305
  end
106
306
 
307
+ # Requires every class in the component to implement all the named
308
+ # instance methods. Methods inherited from resolvable superclasses or
309
+ # mixins count.
310
+ #
311
+ # commands.must_implement :perform
312
+ #
313
+ # Rule id: +protocol.must_implement+.
107
314
  def must_implement(*methods)
108
315
  methods.each do |method_name|
109
316
  add_rule(Rules::MustImplementRule.new(name, method_name))
@@ -111,6 +318,12 @@ module ArchSpec
111
318
  self
112
319
  end
113
320
 
321
+ # Requires every class in the component to implement at least one of the
322
+ # named instance methods. Useful when a protocol allows either name.
323
+ #
324
+ # commands.must_implement_one_of :perform, :call
325
+ #
326
+ # Rule id: +protocol.must_implement_one_of+.
114
327
  def must_implement_one_of(*methods)
115
328
  add_rule(Rules::MustImplementOneOfRule.new(name, methods))
116
329
  self
@@ -4,14 +4,15 @@ module ArchSpec
4
4
  module Evaluator
5
5
  extend self
6
6
 
7
- def evaluate(definition, graph, baseline: Baseline.empty)
7
+ def evaluate(definition, graph, todo: Todo.empty)
8
8
  (parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) })
9
9
  .reject { |diagnostic| graph.suppressed?(diagnostic) }
10
- .reject { |diagnostic| baseline.include?(diagnostic) }
10
+ .reject { |diagnostic| todo.include?(diagnostic) }
11
11
  .sort_by do |diagnostic|
12
12
  [diagnostic.location.path, diagnostic.location.line, diagnostic.rule,
13
- diagnostic.message]
13
+ diagnostic.message, diagnostic.evidence]
14
14
  end
15
+ .uniq { |diagnostic| [diagnostic.rule, diagnostic.message, diagnostic.location.path, diagnostic.location.line] }
15
16
  end
16
17
 
17
18
  private
@@ -71,7 +71,7 @@ module ArchSpec
71
71
  end
72
72
  end
73
73
 
74
- Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence)
74
+ Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence, :receiver)
75
75
 
76
76
  class Component
77
77
  attr_reader :name, :files, :constants, :file_reasons, :constant_reasons
@@ -104,6 +104,8 @@ module ArchSpec
104
104
  extends
105
105
  ].freeze
106
106
 
107
+ RESOLVED_ROOTS = %w[Object BasicObject].freeze
108
+
107
109
  attr_reader :root, :files, :constants, :edges, :components
108
110
 
109
111
  def initialize(root)
@@ -136,8 +138,8 @@ module ArchSpec
136
138
  constant
137
139
  end
138
140
 
139
- def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high)
140
- edges << Edge.new(type, from_path, from_constant, normalize_constant(to), location, confidence)
141
+ def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil)
142
+ edges << Edge.new(type, from_path, from_constant, normalize_constant(to), location, confidence, receiver)
141
143
  end
142
144
 
143
145
  def constants_named(name)
@@ -225,6 +227,39 @@ module ArchSpec
225
227
  candidates.find { |candidate| constants_named(candidate).any? } || normalized
226
228
  end
227
229
 
230
+ # Instance methods a constant responds to, walking resolvable superclasses
231
+ # and include/prepend mixins. Returns [methods, unresolved ancestor names];
232
+ # a non-empty second element means the answer is incomplete.
233
+ def effective_instance_methods(name, visited = Set.new)
234
+ normalized = normalize_constant(name)
235
+ return [Set.new, Set.new] if visited.include?(normalized)
236
+
237
+ visited.add(normalized)
238
+ return [Set.new, Set.new] if RESOLVED_ROOTS.include?(normalized)
239
+
240
+ nodes = constants_named(normalized)
241
+ return [Set.new, Set[normalized]] if nodes.empty?
242
+
243
+ methods = Set.new
244
+ unresolved = Set.new
245
+
246
+ nodes.each do |node|
247
+ methods.merge(node.instance_methods)
248
+
249
+ ancestors = node.mixins[:include].to_a + node.mixins[:prepend].to_a
250
+ ancestors << node.superclass if node.superclass
251
+
252
+ ancestors.each do |ancestor|
253
+ resolved_name = resolve_constant_reference(ancestor, node.name)
254
+ ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
255
+ methods.merge(ancestor_methods)
256
+ unresolved.merge(ancestor_unresolved)
257
+ end
258
+ end
259
+
260
+ [methods, unresolved]
261
+ end
262
+
228
263
  def component_dependency_pairs(only: nil)
229
264
  allowed_sources = Array(only).compact.map(&:to_sym).to_set
230
265
  pairs = Set.new
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
+ # Backwards-compatible alias for ArchSpec::Architectures. New code should use
5
+ # +architecture+ in the DSL and the ArchSpec::Architectures module.
4
6
  module Presets
5
7
  module_function
6
8
 
9
+ # Delegates to ArchSpec::Architectures.apply.
7
10
  def apply(name, dsl, **options)
8
11
  Architectures.apply(name, dsl, **options)
9
12
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ArchSpec
4
4
  module Rules
5
+ # Backs ArchSpec::DSL::ComponentProxy#must_be_empty. Flags every file in a
6
+ # component that is meant to hold none.
5
7
  class MustBeEmptyRule
6
8
  attr_reader :source, :because
7
9
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Rules
5
+ # Backs ArchSpec::DSL::ComponentProxy#cannot_reference_includers. A concern
6
+ # is a module mixed into other classes; this flags a concern that names the
7
+ # very constant that includes it, which is a circular knowledge dependency.
8
+ class ConcernIndependenceRule
9
+ MIXIN_TYPES = %i[includes prepends extends].freeze
10
+
11
+ attr_reader :source
12
+
13
+ def initialize(source)
14
+ @source = source.to_sym
15
+ end
16
+
17
+ def merge_key
18
+ [self.class, source]
19
+ end
20
+
21
+ def id
22
+ 'concerns.independence'
23
+ end
24
+
25
+ def evaluate(graph)
26
+ component = graph.components[source]
27
+ return [] unless component
28
+
29
+ includers = includers_by_module(graph)
30
+
31
+ graph.dependency_edges.filter_map do |edge|
32
+ next unless edge.from_constant && component.constants.include?(edge.from_constant)
33
+
34
+ consumers = includers[edge.from_constant]
35
+ next if consumers.empty?
36
+
37
+ target = graph.resolve_constant_reference(edge.to, edge.from_constant)
38
+ includer = consumers.find { |name| target == name || target.start_with?("#{name}::") }
39
+ next unless includer
40
+
41
+ Diagnostic.new(
42
+ rule: id,
43
+ message: "#{edge.from_constant} must not reference its includer #{includer}",
44
+ location: edge.location,
45
+ evidence: "#{edge.from_constant} #{edge.type} #{target}"
46
+ )
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # Maps each mixed-in module to the set of constants that mix it in.
53
+ def includers_by_module(graph)
54
+ map = Hash.new { |hash, key| hash[key] = Set.new }
55
+
56
+ graph.edges.each do |edge|
57
+ next unless MIXIN_TYPES.include?(edge.type) && edge.from_constant
58
+
59
+ mod = graph.resolve_constant_reference(edge.to, edge.from_constant)
60
+ map[mod].add(edge.from_constant) unless mod == edge.from_constant
61
+ end
62
+
63
+ map
64
+ end
65
+ end
66
+ end
67
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ArchSpec
4
4
  module Rules
5
+ # Backs ArchSpec::DSL::Context#no_cycles!. Flags dependency cycles between
6
+ # components, reporting each cycle once in a canonical order.
5
7
  class NoCyclesRule
6
8
  attr_reader :components
7
9
 
@@ -1,7 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
+ # The checks ArchSpec runs. Each rule responds to +id+ (its stable rule id,
5
+ # used in suppressions and the todo file) and <tt>evaluate(graph)</tt>, which
6
+ # returns ArchSpec::Diagnostic objects. You normally add rules through the
7
+ # ArchSpec::DSL rather than instantiating these directly, but you can pass a
8
+ # custom object of the same shape to ArchSpec::DSL::Context#rule.
4
9
  module Rules
10
+ # Base for the allow and forbid dependency rules. Merges repeated
11
+ # declarations for the same source component.
5
12
  class DependencyRule
6
13
  attr_reader :source, :targets
7
14
 
@@ -34,6 +41,8 @@ module ArchSpec
34
41
  end
35
42
  end
36
43
 
44
+ # Backs ArchSpec::DSL::ComponentProxy#can_use. Flags references from the
45
+ # source to any component outside its allowlist.
37
46
  class AllowDependenciesRule < DependencyRule
38
47
  def id
39
48
  'dependencies.allow'
@@ -55,6 +64,8 @@ module ArchSpec
55
64
  end
56
65
  end
57
66
 
67
+ # Backs ArchSpec::DSL::ComponentProxy#cannot_use. Flags references from the
68
+ # source to any of the named components.
58
69
  class ForbidDependenciesRule < DependencyRule
59
70
  def id
60
71
  'dependencies.forbid'
@@ -76,6 +87,60 @@ module ArchSpec
76
87
  end
77
88
  end
78
89
 
90
+ # Backs ArchSpec::DSL::ComponentProxy#can_only_be_used_by. The inverse of an
91
+ # allowlist: flags references to the component from any component that is not
92
+ # an approved consumer. Use it to protect a shared kernel.
93
+ class AllowedConsumersRule
94
+ attr_reader :source, :consumers
95
+
96
+ def initialize(source, consumers)
97
+ @source = source.to_sym
98
+ @consumers = Array(consumers).flatten.map(&:to_sym).to_set
99
+ end
100
+
101
+ def merge_key
102
+ [self.class, source]
103
+ end
104
+
105
+ def merge!(other)
106
+ consumers.merge(other.consumers)
107
+ self
108
+ end
109
+
110
+ def id
111
+ 'dependencies.consumers'
112
+ end
113
+
114
+ def evaluate(graph)
115
+ graph.dependency_edges.flat_map do |edge|
116
+ next [] unless graph.target_components_for(edge).include?(source)
117
+
118
+ offenders = graph.component_names_for_path(edge.from_path).reject do |component|
119
+ component == source || consumers.include?(component)
120
+ end
121
+
122
+ offenders.map do |offender|
123
+ Diagnostic.new(
124
+ rule: id,
125
+ message: message_for(offender),
126
+ location: edge.location,
127
+ evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge.to}"
128
+ )
129
+ end
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def message_for(offender)
136
+ return "#{source} may not be used by #{offender}" if consumers.empty?
137
+
138
+ "#{source} may only be used by #{consumers.to_a.sort.join(', ')}, not #{offender}"
139
+ end
140
+ end
141
+
142
+ # Backs ArchSpec::DSL::ComponentProxy#cannot_reference_constants. Flags
143
+ # references to the named constants or anything nested under them.
79
144
  class CannotReferenceConstantsRule
80
145
  attr_reader :source, :constants
81
146
 
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Rules
5
+ # Backs ArchSpec::DSL::ComponentProxy#public_api. Flags references from
6
+ # outside the component to constants that are not part of its public API.
7
+ class PublicApiRule
8
+ attr_reader :source, :file_patterns, :constants, :namespaces
9
+
10
+ def initialize(source, files: [], constants: nil, namespaces: nil)
11
+ @source = source.to_sym
12
+ @file_patterns = Array(files).flatten.compact.map(&:to_s)
13
+ @constants = Array(constants).compact.map { |value| normalize_constant(value) }
14
+ @namespaces = Array(namespaces).compact.map { |value| normalize_constant(value) }
15
+ end
16
+
17
+ def merge_key
18
+ [self.class, source]
19
+ end
20
+
21
+ def merge!(other)
22
+ @file_patterns |= other.file_patterns
23
+ @constants |= other.constants
24
+ @namespaces |= other.namespaces
25
+ self
26
+ end
27
+
28
+ def id
29
+ 'dependencies.privacy'
30
+ end
31
+
32
+ def evaluate(graph)
33
+ component = graph.components[source]
34
+ return [] unless component
35
+
36
+ public_names = public_constant_names(graph)
37
+
38
+ graph.dependency_edges.filter_map do |edge|
39
+ next if component.files.include?(edge.from_path)
40
+
41
+ resolved = graph.resolve_constant_reference(edge.to, edge.from_constant)
42
+ next unless graph.component_names_for_constant(resolved).include?(source)
43
+ next if public?(resolved, public_names)
44
+
45
+ Diagnostic.new(
46
+ rule: id,
47
+ message: "#{resolved} is private to #{source}",
48
+ location: edge.location,
49
+ evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{resolved}"
50
+ )
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def public_constant_names(graph)
57
+ paths = file_patterns.flat_map do |pattern|
58
+ Dir.glob(File.absolute_path(pattern, graph.root))
59
+ end.map { |path| File.expand_path(path) }.to_set
60
+
61
+ names = graph.constants.select { |constant| paths.include?(constant.path) }.map(&:name)
62
+ (names + constants).to_set
63
+ end
64
+
65
+ # Names from public files and constants: match exactly. Reopened
66
+ # namespace modules land in public files too, so prefix matching there
67
+ # would make the whole namespace public. Use namespace: for prefixes.
68
+ def public?(name, public_names)
69
+ return true if public_names.include?(name)
70
+
71
+ namespaces.any? do |namespace|
72
+ name == namespace || name.start_with?("#{namespace}::")
73
+ end
74
+ end
75
+
76
+ def normalize_constant(value)
77
+ value.to_s.sub(/\A::/, '')
78
+ end
79
+ end
80
+ end
81
+ end