archspec 0.3.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.
data/lib/archspec/dsl.rb CHANGED
@@ -1,26 +1,92 @@
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
+ # Yields each subdirectory matching a glob, so you can declare one
60
+ # component per engine or pack without hardcoding their names. Paths
61
+ # resolve against the +Archspec.rb+ directory, not the working directory,
62
+ # so it does not matter where +archspec+ is run from.
63
+ #
64
+ # each_directory "engines/*" do |name, path|
65
+ # component name, in: "#{path}/**/*.rb"
66
+ # end
67
+ #
68
+ # Yields the directory basename and its root-relative path. Returns the
69
+ # <tt>[name, path]</tt> pairs when called without a block.
70
+ def each_directory(glob)
71
+ base = absolute_root
72
+ pairs = Dir.glob(File.join(base, glob)).select { |path| File.directory?(path) }.sort.map do |absolute|
73
+ [File.basename(absolute), Pathname(absolute).relative_path_from(Pathname(base)).to_s]
74
+ end
75
+
76
+ return pairs unless block_given?
77
+
78
+ pairs.each { |name, path| yield(name, path) }
22
79
  end
23
80
 
81
+ # Declares a component: a named set of files, matched by glob, namespace,
82
+ # or explicit constant.
83
+ #
84
+ # component :services, in: "app/services/**/*.rb"
85
+ # component :billing, namespace: "Billing"
86
+ # component :legacy, constants: %w[OldReport OldExport]
87
+ #
88
+ # Returns an ArchSpec::DSL::ComponentProxy for attaching rules. The
89
+ # component is also available by name later in the file.
24
90
  def component(name, in: nil, namespace: nil, constants: nil)
25
91
  add_component(
26
92
  ComponentSpec.new(name, files: binding.local_variable_get(:in), namespace: namespace, constants: constants)
@@ -28,21 +94,38 @@ module ArchSpec
28
94
  ComponentProxy.new(self, name)
29
95
  end
30
96
 
31
- alias layer component
32
- alias role component
33
-
97
+ # Applies a bundled architecture preset, defining its components and
98
+ # rules together.
99
+ #
100
+ # architecture :rails
101
+ # architecture :hexagonal
102
+ # architecture :modular_monolith, components: { ... }, allow: { ... }
103
+ #
104
+ # +preset+ is an alias. Use whichever word fits: +architecture+ reads well
105
+ # for structural bundles like +:rails+, +preset+ for convention packs like
106
+ # +:ruby_conventions+.
107
+ #
108
+ # See ArchSpec::Architectures for every preset and its options.
34
109
  def architecture(name, **options)
35
110
  Architectures.apply(name, self, **options)
36
111
  end
37
112
 
38
- def no_cycles!(among: nil)
39
- add_rule(Rules::NoCyclesRule.new(among: among))
40
- end
113
+ alias preset architecture
41
114
 
42
- def verify_zeitwerk_names!
43
- add_rule(Rules::ZeitwerkNamingRule.new)
115
+ # Forbids dependency cycles between components. Pass +among:+ to limit the
116
+ # check to a subset; omit it to check every declared component.
117
+ #
118
+ # no_cycles
119
+ # no_cycles among: %i[billing catalog shared]
120
+ #
121
+ # Rule id: +dependencies.no_cycles+.
122
+ def no_cycles(among: nil)
123
+ add_rule(Rules::NoCyclesRule.new(among: among))
44
124
  end
45
125
 
126
+ # Adds a custom rule object. A rule responds to +id+ and
127
+ # <tt>evaluate(graph)</tt>, returning ArchSpec::Diagnostic objects. Use
128
+ # this to extend ArchSpec with project-specific checks.
46
129
  def rule(rule)
47
130
  add_rule(rule)
48
131
  end
@@ -58,6 +141,11 @@ module ArchSpec
58
141
  end
59
142
  end
60
143
 
144
+ # A handle to one component, returned by ArchSpec::DSL::Context#component
145
+ # and by calling a declared component's name. Rule methods return +self+, so
146
+ # they chain.
147
+ #
148
+ # services.cannot_use(:controllers).cannot_call(:render, receiver: :none)
61
149
  class ComponentProxy
62
150
  attr_reader :definition, :name
63
151
 
@@ -66,44 +154,139 @@ module ArchSpec
66
154
  @name = name.to_sym
67
155
  end
68
156
 
69
- def can_use(*targets)
157
+ # Allowlists the components this one may depend on: only the listed
158
+ # components are permitted, and a reference to any other declared
159
+ # component fails. The mirror image of #can_only_be_used_by.
160
+ #
161
+ # controllers.can_only_use :models, :services
162
+ #
163
+ # Rule id: +dependencies.allow+.
164
+ def can_only_use(*targets)
70
165
  add_rule(Rules::AllowDependenciesRule.new(name, targets))
71
166
  self
72
167
  end
73
168
 
74
- alias only_depend_on can_use
75
- alias must_only_depend_on can_use
76
-
169
+ # Forbids depending on the named components. Narrower than #can_only_use:
170
+ # only the listed components fail, other dependencies are left alone.
171
+ #
172
+ # models.cannot_use :controllers, :helpers
173
+ #
174
+ # Rule id: +dependencies.forbid+.
77
175
  def cannot_use(*targets)
78
176
  add_rule(Rules::ForbidDependenciesRule.new(name, targets))
79
177
  self
80
178
  end
81
179
 
82
- def cannot_call(*methods)
83
- add_rule(Rules::CannotCallRule.new(name, methods))
180
+ # Allowlists the components that may reference this one, the inverse of
181
+ # #can_only_use. A reference from any other component fails. Use it to protect
182
+ # a shared kernel or a component with a deliberately narrow audience.
183
+ #
184
+ # shared_kernel.can_only_be_used_by :billing, :catalog
185
+ #
186
+ # Rule id: +dependencies.consumers+.
187
+ def can_only_be_used_by(*consumers)
188
+ add_rule(Rules::AllowedConsumersRule.new(name, consumers))
84
189
  self
85
190
  end
86
191
 
192
+ # Forbids calling the named methods. By default any receiver matches, so
193
+ # this catches +record.update+ and +cache.update+ alike. Pass
194
+ # <tt>receiver: :none</tt> to match only bare, implicit-+self+ calls, which
195
+ # is how the Rails presets keep the controller API out of models.
196
+ #
197
+ # queries.cannot_call :save, :update, :destroy
198
+ # services.cannot_call :render, :params, receiver: :none
199
+ #
200
+ # A bare call to a method the component defines, inherits, or generates
201
+ # with +attr_*+, Rails +attribute+, or +delegate+ is treated as its own API
202
+ # and not flagged.
203
+ # Rule id: +methods.forbid+.
204
+ def cannot_call(*methods, receiver: :any)
205
+ add_rule(Rules::CannotCallRule.new(name, methods, receiver: receiver))
206
+ self
207
+ end
208
+
209
+ # Forbids defining the named methods in this component. Use it when the
210
+ # method name itself is a design smell there, such as +call+ on a
211
+ # component that should not hold command objects.
212
+ #
213
+ # models.cannot_define :call
214
+ #
215
+ # Rule id: +methods.define_forbid+.
87
216
  def cannot_define(*methods)
88
217
  add_rule(Rules::CannotDefineMethodRule.new(name, methods))
89
218
  self
90
219
  end
91
220
 
221
+ # Forbids the one-shot <tt>Thing.new(...).call</tt> pattern, where a class
222
+ # is instantiated and immediately invoked. Use it to steer a component
223
+ # toward plain methods over anonymous command objects.
224
+ #
225
+ # Rule id: +objects.instantiate_and_invoke_forbid+.
92
226
  def cannot_instantiate_and_invoke
93
227
  add_rule(Rules::CannotInstantiateAndInvokeRule.new(name))
94
228
  self
95
229
  end
96
230
 
231
+ # Forbids referencing the named constants or anything under them. Use this
232
+ # when the boundary is a framework constant rather than a component.
233
+ #
234
+ # models.cannot_reference_constants "ActionController", "ActionView"
235
+ #
236
+ # Rule id: +constants.forbid+.
97
237
  def cannot_reference_constants(*constants)
98
238
  add_rule(Rules::CannotReferenceConstantsRule.new(name, constants))
99
239
  self
100
240
  end
101
241
 
242
+ # Marks part of the component as its public API. References from outside
243
+ # must resolve to a public constant; everything else becomes private.
244
+ #
245
+ # billing.public_api "packs/billing/app/public/**/*.rb"
246
+ # billing.public_api constants: "Billing::Api"
247
+ # billing.public_api namespace: "Billing::Public"
248
+ #
249
+ # +constants+ matches exact names, +namespace+ matches a name and its
250
+ # children. Code inside the component may still reach its own internals.
251
+ # Rule id: +dependencies.privacy+.
252
+ def public_api(*patterns, constants: nil, namespace: nil)
253
+ add_rule(Rules::PublicApiRule.new(name, files: patterns, constants: constants, namespaces: namespace))
254
+ self
255
+ end
256
+
257
+ # Forbids a concern from referencing the constants that include it. A
258
+ # concern that names its includer knows too much about who uses it, which
259
+ # couples the two and defeats the point of extracting the concern.
260
+ #
261
+ # component :model_concerns, in: "app/models/concerns/**/*.rb"
262
+ # model_concerns.cannot_reference_includers
263
+ #
264
+ # Rule id: +concerns.independence+.
265
+ def cannot_reference_includers
266
+ add_rule(Rules::ConcernIndependenceRule.new(name))
267
+ self
268
+ end
269
+
270
+ # Requires the component to hold no files. Use it to keep a directory
271
+ # empty, such as +app/services+ in a vanilla Rails app, with a reason
272
+ # shown in the diagnostic.
273
+ #
274
+ # component(:services, in: "app/services/**/*.rb")
275
+ # .must_be_empty(because: "behavior belongs on models")
276
+ #
277
+ # Rule id: +components.empty+.
102
278
  def must_be_empty(because: nil)
103
279
  add_rule(Rules::MustBeEmptyRule.new(name, because: because))
104
280
  self
105
281
  end
106
282
 
283
+ # Requires every class in the component to implement all the named
284
+ # instance methods. Methods inherited from resolvable superclasses or
285
+ # mixins count.
286
+ #
287
+ # commands.must_implement :perform
288
+ #
289
+ # Rule id: +protocol.must_implement+.
107
290
  def must_implement(*methods)
108
291
  methods.each do |method_name|
109
292
  add_rule(Rules::MustImplementRule.new(name, method_name))
@@ -111,11 +294,32 @@ module ArchSpec
111
294
  self
112
295
  end
113
296
 
297
+ # Requires every class in the component to implement at least one of the
298
+ # named instance methods. Useful when a protocol allows either name.
299
+ #
300
+ # commands.must_implement_one_of :perform, :call
301
+ #
302
+ # Rule id: +protocol.must_implement_one_of+.
114
303
  def must_implement_one_of(*methods)
115
304
  add_rule(Rules::MustImplementOneOfRule.new(name, methods))
116
305
  self
117
306
  end
118
307
 
308
+ # Starts a naming-convention rule over the component's defined, public
309
+ # methods. Select the methods with +matching+, then assert something about
310
+ # them. Every check is name-based and exact.
311
+ #
312
+ # models.method_names.matching(/\A(get|set)_/).forbidden
313
+ # chat.method_names.matching(/\Awith_(?<base>.+)/).requires("without_%{base}")
314
+ # chat.method_names.matching(/\Awith_(?<b>.+)/).requires("%{b}", on: agent, scope: :class)
315
+ #
316
+ # Pass <tt>scope: :class</tt> to select class methods instead of instance
317
+ # methods. See ArchSpec::Rules::Naming::Selected for the constraints
318
+ # (+forbidden+, +requires+). Rule ids: +naming.forbidden+, +naming.requires+.
319
+ def method_names(scope: :instance)
320
+ Rules::Naming::Builder.new(self, scope: scope)
321
+ end
322
+
119
323
  private
120
324
 
121
325
  def add_rule(rule)
@@ -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
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Formatters
5
+ # Renders <tt>archspec explain</tt>: why a file or constant belongs to its
6
+ # components, and the facts ArchSpec found for it. Raises ArchSpec::Error
7
+ # when the subject matches no file and no constant.
8
+ module Explanation
9
+ module_function
10
+
11
+ def print(output = $stdout, graph:, subject:)
12
+ path = File.expand_path(subject, graph.root)
13
+
14
+ if graph.files.key?(path)
15
+ explain_file(output, graph, path)
16
+ else
17
+ explain_constant(output, graph, subject)
18
+ end
19
+ end
20
+
21
+ def explain_file(output, graph, path)
22
+ file = graph.files.fetch(path)
23
+ output.puts file.relative_path
24
+ output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(', ')}"
25
+ print_parse_errors(output, file)
26
+ print_component_reasons(output, graph.component_assignment_reasons_for_path(path))
27
+ print_suppressions(output, file)
28
+ output.puts ' outgoing facts:'
29
+
30
+ graph.edges.select { |edge| edge.from_path == path }.each do |edge|
31
+ output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
32
+ end
33
+ end
34
+
35
+ def explain_constant(output, graph, subject)
36
+ constants = graph.constants_named(subject)
37
+ raise Error, "No file or constant found for #{subject.inspect}" if constants.empty?
38
+
39
+ constants.each do |constant|
40
+ output.puts constant.name
41
+ output.puts " kind: #{constant.kind}"
42
+ output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
43
+ print_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
44
+ output.puts " superclass: #{constant.superclass || '(none)'}"
45
+ output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(', ')}"
46
+ output.puts " class methods: #{constant.class_methods.to_a.sort.join(', ')}"
47
+ end
48
+ end
49
+
50
+ def print_component_reasons(output, assignments)
51
+ if assignments.empty?
52
+ output.puts ' components: (none)'
53
+ return
54
+ end
55
+
56
+ output.puts ' components:'
57
+ assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
58
+ output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
59
+ end
60
+ end
61
+
62
+ def print_suppressions(output, file)
63
+ return if file.suppressions.empty?
64
+
65
+ output.puts ' suppressions:'
66
+ file.suppressions.each do |suppression|
67
+ rule = suppression.rule || '*'
68
+ reason = suppression.reason ? " -- #{suppression.reason}" : ''
69
+ output.puts " #{rule} on line #{line_range(suppression)}#{reason}"
70
+ end
71
+ end
72
+
73
+ def print_parse_errors(output, file)
74
+ return if file.parse_errors.empty?
75
+
76
+ output.puts ' parse errors:'
77
+ file.parse_errors.each do |parse_error|
78
+ output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
79
+ end
80
+ end
81
+
82
+ def line_range(suppression)
83
+ if suppression.end_line == Float::INFINITY
84
+ "#{suppression.start_line}-EOF"
85
+ elsif suppression.start_line == suppression.end_line
86
+ suppression.start_line
87
+ else
88
+ "#{suppression.start_line}-#{suppression.end_line}"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -7,7 +7,7 @@ require_relative 'value_object'
7
7
 
8
8
  module ArchSpec
9
9
  ParseError = ValueObject.define(:message, :location)
10
- MethodDefinition = ValueObject.define(:owner, :name, :scope, :location)
10
+ MethodDefinition = ValueObject.define(:owner, :name, :scope, :location, :visibility)
11
11
 
12
12
  Suppression = ValueObject.define(:rule, :start_line, :end_line, :reason) do
13
13
  def matches?(diagnostic)
@@ -18,12 +18,11 @@ module ArchSpec
18
18
  end
19
19
 
20
20
  class SourceFile
21
- attr_reader :path, :relative_path, :expected_constant, :parse_errors, :suppressions
21
+ attr_reader :path, :relative_path, :parse_errors, :suppressions
22
22
 
23
- def initialize(root:, path:, expected_constant:, parse_errors:, suppressions:)
23
+ def initialize(root:, path:, parse_errors:, suppressions:)
24
24
  @path = path
25
25
  @relative_path = Pathname(path).relative_path_from(Pathname(root)).to_s
26
- @expected_constant = expected_constant
27
26
  @parse_errors = parse_errors
28
27
  @suppressions = suppressions
29
28
  end
@@ -56,22 +55,31 @@ module ArchSpec
56
55
  kind == :module
57
56
  end
58
57
 
59
- def add_instance_method(name, location:)
58
+ def add_instance_method(name, location:, visibility: :public)
60
59
  instance_methods.add(name.to_sym)
61
- method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location)
60
+ method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location, visibility)
62
61
  end
63
62
 
64
- def add_class_method(name, location:)
63
+ def add_class_method(name, location:, visibility: :public)
65
64
  class_methods.add(name.to_sym)
66
- method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location)
65
+ method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location, visibility)
67
66
  end
68
67
 
69
68
  def add_mixin(kind, name)
70
69
  mixins.fetch(kind).add(name)
71
70
  end
71
+
72
+ # Rewrites the visibility of already-recorded definitions, for the
73
+ # <tt>private :foo, :bar</tt> form that names methods defined earlier.
74
+ def set_visibility(name, scope, visibility)
75
+ name = name.to_sym
76
+ method_definitions.map! do |definition|
77
+ definition.name == name && definition.scope == scope ? definition.with(visibility: visibility) : definition
78
+ end
79
+ end
72
80
  end
73
81
 
74
- Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence)
82
+ Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence, :receiver)
75
83
 
76
84
  class Component
77
85
  attr_reader :name, :files, :constants, :file_reasons, :constant_reasons
@@ -104,6 +112,8 @@ module ArchSpec
104
112
  extends
105
113
  ].freeze
106
114
 
115
+ RESOLVED_ROOTS = %w[Object BasicObject].freeze
116
+
107
117
  attr_reader :root, :files, :constants, :edges, :components
108
118
 
109
119
  def initialize(root)
@@ -115,11 +125,10 @@ module ArchSpec
115
125
  @components = {}
116
126
  end
117
127
 
118
- def add_file(path:, expected_constant:, parse_errors:, suppressions: [])
128
+ def add_file(path:, parse_errors:, suppressions: [])
119
129
  files[path] = SourceFile.new(
120
130
  root: root,
121
131
  path: path,
122
- expected_constant: expected_constant,
123
132
  parse_errors: parse_errors,
124
133
  suppressions: suppressions
125
134
  )
@@ -136,8 +145,8 @@ module ArchSpec
136
145
  constant
137
146
  end
138
147
 
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)
148
+ def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil)
149
+ edges << Edge.new(type, from_path, from_constant, normalize_constant(to), location, confidence, receiver)
141
150
  end
142
151
 
143
152
  def constants_named(name)
@@ -155,6 +164,12 @@ module ArchSpec
155
164
  component.constants.flat_map { |constant_name| constants_named(constant_name) }.flat_map(&:method_definitions)
156
165
  end
157
166
 
167
+ # Every method definition in the graph, across all constants. Used by
168
+ # project-wide naming rules that are not scoped to one component.
169
+ def method_definitions
170
+ constants.flat_map(&:method_definitions)
171
+ end
172
+
158
173
  def assign_components(component_specs)
159
174
  @components = {}
160
175
 
@@ -225,6 +240,39 @@ module ArchSpec
225
240
  candidates.find { |candidate| constants_named(candidate).any? } || normalized
226
241
  end
227
242
 
243
+ # Instance methods a constant responds to, walking resolvable superclasses
244
+ # and include/prepend mixins. Returns [methods, unresolved ancestor names];
245
+ # a non-empty second element means the answer is incomplete.
246
+ def effective_instance_methods(name, visited = Set.new)
247
+ normalized = normalize_constant(name)
248
+ return [Set.new, Set.new] if visited.include?(normalized)
249
+
250
+ visited.add(normalized)
251
+ return [Set.new, Set.new] if RESOLVED_ROOTS.include?(normalized)
252
+
253
+ nodes = constants_named(normalized)
254
+ return [Set.new, Set[normalized]] if nodes.empty?
255
+
256
+ methods = Set.new
257
+ unresolved = Set.new
258
+
259
+ nodes.each do |node|
260
+ methods.merge(node.instance_methods)
261
+
262
+ ancestors = node.mixins[:include].to_a + node.mixins[:prepend].to_a
263
+ ancestors << node.superclass if node.superclass
264
+
265
+ ancestors.each do |ancestor|
266
+ resolved_name = resolve_constant_reference(ancestor, node.name)
267
+ ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
268
+ methods.merge(ancestor_methods)
269
+ unresolved.merge(ancestor_unresolved)
270
+ end
271
+ end
272
+
273
+ [methods, unresolved]
274
+ end
275
+
228
276
  def component_dependency_pairs(only: nil)
229
277
  allowed_sources = Array(only).compact.map(&:to_sym).to_set
230
278
  pairs = Set.new
@@ -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