archspec 1.0.1 → 1.1.0.rc2

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.
@@ -296,9 +296,10 @@ module ArchSpec
296
296
  Rules::NamingRule.new(
297
297
  source: nil,
298
298
  selector: Rules::Naming::NameSelector.new(regex),
299
- constraint: Rules::Naming::Forbidden.new(because: reason),
299
+ constraint: Rules::Naming::Forbidden.new,
300
300
  scope: scope
301
- )
301
+ ),
302
+ because: reason
302
303
  )
303
304
  end
304
305
 
@@ -314,11 +315,14 @@ module ArchSpec
314
315
 
315
316
  def define_component(dsl, name, selector)
316
317
  if selector.is_a?(Hash)
318
+ selector = selector.to_h.transform_keys(&:to_sym)
317
319
  dsl.component(
318
320
  name,
319
321
  in: selector[:in] || selector[:files],
322
+ except: selector[:except],
320
323
  namespace: selector[:namespace],
321
- constants: selector[:constants]
324
+ constants: selector[:constants],
325
+ descendants_of: selector[:descendants_of]
322
326
  )
323
327
  else
324
328
  dsl.component(name, in: selector)
@@ -5,19 +5,23 @@ module ArchSpec
5
5
  # explicit constant name. Created by ArchSpec::DSL::Context#component. The
6
6
  # analyzer uses it to assign files and constants to the component.
7
7
  class ComponentSpec
8
- attr_reader :name, :file_patterns, :namespaces, :constants
8
+ attr_reader :name, :file_patterns, :exclude_patterns, :namespaces, :constants, :ancestor_names
9
9
 
10
- def initialize(name, files: [], namespace: nil, constants: nil)
10
+ def initialize(name, files: [], except: [], namespace: nil, constants: nil, descendants_of: nil)
11
11
  @name = name.to_sym
12
12
  @file_patterns = Array(files).compact.map(&:to_s)
13
+ @exclude_patterns = Array(except).compact.map(&:to_s)
13
14
  @namespaces = Array(namespace).compact.map { |value| normalize_constant(value) }
14
15
  @constants = Array(constants).compact.map { |value| normalize_constant(value) }
16
+ @ancestor_names = Array(descendants_of).compact.map { |value| normalize_constant(value) }
15
17
  end
16
18
 
17
19
  def merge!(other)
18
20
  @file_patterns |= other.file_patterns
21
+ @exclude_patterns |= other.exclude_patterns
19
22
  @namespaces |= other.namespaces
20
23
  @constants |= other.constants
24
+ @ancestor_names |= other.ancestor_names
21
25
  self
22
26
  end
23
27
 
@@ -30,6 +34,13 @@ module ArchSpec
30
34
  end
31
35
  end
32
36
 
37
+ def matching_ancestor(name, graph)
38
+ return if ancestor_names.empty?
39
+
40
+ ancestors = graph.ancestor_names(name).first
41
+ ancestor_names.find { |ancestor| ancestors.include?(ancestor) }
42
+ end
43
+
33
44
  private
34
45
 
35
46
  def normalize_constant(value)
@@ -8,14 +8,28 @@ module ArchSpec
8
8
  # these. Its #fingerprint is the stable id used to match todo entries and
9
9
  # suppress specific findings.
10
10
  class Diagnostic
11
- attr_reader :rule, :message, :location, :evidence, :confidence
11
+ attr_reader :rule, :message, :location, :evidence, :confidence, :reason
12
12
 
13
- def initialize(rule:, message:, location:, evidence:, confidence: :high)
13
+ def initialize(rule:, message:, location:, evidence:, confidence: :high, reason: nil)
14
14
  @rule = rule
15
15
  @message = message
16
16
  @location = location
17
17
  @evidence = evidence
18
18
  @confidence = confidence
19
+ @reason = reason
20
+ end
21
+
22
+ def with_reason(reason)
23
+ return self unless reason
24
+
25
+ self.class.new(
26
+ rule: rule,
27
+ message: message,
28
+ location: location,
29
+ evidence: evidence,
30
+ confidence: confidence,
31
+ reason: reason
32
+ )
19
33
  end
20
34
 
21
35
  # Line numbers stay out of the fingerprint so todo entries survive edits
@@ -29,7 +43,7 @@ module ArchSpec
29
43
  end
30
44
 
31
45
  def to_h(root:)
32
- {
46
+ hash = {
33
47
  id: fingerprint(root: root),
34
48
  rule: rule,
35
49
  message: message,
@@ -41,6 +55,8 @@ module ArchSpec
41
55
  evidence: evidence,
42
56
  confidence: confidence.to_s
43
57
  }
58
+ hash[:reason] = reason if reason
59
+ hash
44
60
  end
45
61
  end
46
62
  end
data/lib/archspec/dsl.rb CHANGED
@@ -89,17 +89,26 @@ module ArchSpec
89
89
  end
90
90
 
91
91
  # Declares a component: a named set of files, matched by glob, namespace,
92
- # or explicit constant.
92
+ # explicit constant, or semantic ancestry.
93
93
  #
94
94
  # component :services, in: "app/services/**/*.rb"
95
+ # component :workflows, in: "app/models/**/*.rb", except: "app/models/legacy/**/*.rb"
95
96
  # component :billing, namespace: "Billing"
96
97
  # component :legacy, constants: %w[OldReport OldExport]
98
+ # component :records, descendants_of: "ApplicationRecord"
97
99
  #
98
100
  # Returns an ArchSpec::DSL::ComponentProxy for attaching rules. The
99
101
  # component is also available by name later in the file.
100
- def component(name, in: nil, namespace: nil, constants: nil)
102
+ def component(name, in: nil, except: nil, namespace: nil, constants: nil, descendants_of: nil)
101
103
  add_component(
102
- ComponentSpec.new(name, files: binding.local_variable_get(:in), namespace: namespace, constants: constants)
104
+ ComponentSpec.new(
105
+ name,
106
+ files: binding.local_variable_get(:in),
107
+ except: except,
108
+ namespace: namespace,
109
+ constants: constants,
110
+ descendants_of: descendants_of
111
+ )
103
112
  )
104
113
  ComponentProxy.new(self, name)
105
114
  end
@@ -129,16 +138,16 @@ module ArchSpec
129
138
  # no_cycles among: %i[billing catalog shared]
130
139
  #
131
140
  # Rule id: +dependencies.no_cycles+.
132
- def no_cycles(among: nil)
141
+ def no_cycles(among: nil, because: nil)
133
142
  DSL.assert_known_components!(self, among, for_rule: 'no_cycles') if among
134
- add_rule(Rules::NoCyclesRule.new(among: among))
143
+ add_rule(Rules.with_reason(Rules::NoCyclesRule.new(among: among), because))
135
144
  end
136
145
 
137
146
  # Adds a custom rule object. A rule responds to +id+ and
138
147
  # <tt>evaluate(graph)</tt>, returning ArchSpec::Diagnostic objects. Use
139
148
  # this to extend ArchSpec with project-specific checks.
140
- def rule(rule)
141
- add_rule(rule)
149
+ def rule(rule, because: nil)
150
+ add_rule(Rules.with_reason(rule, because))
142
151
  end
143
152
 
144
153
  def method_missing(name, ...)
@@ -172,9 +181,9 @@ module ArchSpec
172
181
  # controllers.can_only_use :models, :services
173
182
  #
174
183
  # Rule id: +dependencies.allow+.
175
- def can_only_use(*targets)
184
+ def can_only_use(*targets, because: nil)
176
185
  DSL.assert_known_components!(definition, targets, for_rule: "#{name}.can_only_use")
177
- add_rule(Rules::AllowDependenciesRule.new(name, targets))
186
+ add_rule(Rules::AllowDependenciesRule.new(name, targets), because: because)
178
187
  self
179
188
  end
180
189
 
@@ -184,9 +193,9 @@ module ArchSpec
184
193
  # models.cannot_use :controllers, :helpers
185
194
  #
186
195
  # Rule id: +dependencies.forbid+.
187
- def cannot_use(*targets)
196
+ def cannot_use(*targets, because: nil)
188
197
  DSL.assert_known_components!(definition, targets, for_rule: "#{name}.cannot_use")
189
- add_rule(Rules::ForbidDependenciesRule.new(name, targets))
198
+ add_rule(Rules::ForbidDependenciesRule.new(name, targets), because: because)
190
199
  self
191
200
  end
192
201
 
@@ -197,26 +206,27 @@ module ArchSpec
197
206
  # shared_kernel.can_only_be_used_by :billing, :catalog
198
207
  #
199
208
  # Rule id: +dependencies.consumers+.
200
- def can_only_be_used_by(*consumers)
209
+ def can_only_be_used_by(*consumers, because: nil)
201
210
  DSL.assert_known_components!(definition, consumers, for_rule: "#{name}.can_only_be_used_by")
202
- add_rule(Rules::AllowedConsumersRule.new(name, consumers))
211
+ add_rule(Rules::AllowedConsumersRule.new(name, consumers), because: because)
203
212
  self
204
213
  end
205
214
 
206
215
  # Forbids calling the named methods. By default any receiver matches, so
207
216
  # this catches +record.update+ and +cache.update+ alike. Pass
208
- # <tt>receiver: :none</tt> to match only bare, implicit-+self+ calls, which
209
- # is how the Rails presets keep the controller API out of models.
217
+ # <tt>receiver: :none</tt> to match only bare, implicit-+self+ calls, or a
218
+ # constant name to match that semantic class receiver and its descendants.
210
219
  #
211
220
  # queries.cannot_call :save, :update, :destroy
212
221
  # services.cannot_call :render, :params, receiver: :none
222
+ # models.cannot_call :find_by_sql, receiver: "ActiveRecord::Base"
213
223
  #
214
224
  # A bare call to a method the component defines, inherits, or generates
215
225
  # with +attr_*+, Rails +attribute+, or +delegate+ is treated as its own API
216
- # and not flagged.
226
+ # and not flagged. Resolved method aliases are matched to their target.
217
227
  # Rule id: +methods.forbid+.
218
- def cannot_call(*methods, receiver: :any)
219
- add_rule(Rules::CannotCallRule.new(name, methods, receiver: receiver))
228
+ def cannot_call(*methods, receiver: :any, because: nil)
229
+ add_rule(Rules::CannotCallRule.new(name, methods, receiver: receiver), because: because)
220
230
  self
221
231
  end
222
232
 
@@ -227,8 +237,8 @@ module ArchSpec
227
237
  # models.cannot_define :call
228
238
  #
229
239
  # Rule id: +methods.define_forbid+.
230
- def cannot_define(*methods)
231
- add_rule(Rules::CannotDefineMethodRule.new(name, methods))
240
+ def cannot_define(*methods, because: nil)
241
+ add_rule(Rules::CannotDefineMethodRule.new(name, methods), because: because)
232
242
  self
233
243
  end
234
244
 
@@ -237,8 +247,8 @@ module ArchSpec
237
247
  # toward plain methods over anonymous command objects.
238
248
  #
239
249
  # Rule id: +objects.instantiate_and_invoke_forbid+.
240
- def cannot_instantiate_and_invoke
241
- add_rule(Rules::CannotInstantiateAndInvokeRule.new(name))
250
+ def cannot_instantiate_and_invoke(because: nil)
251
+ add_rule(Rules::CannotInstantiateAndInvokeRule.new(name), because: because)
242
252
  self
243
253
  end
244
254
 
@@ -248,8 +258,8 @@ module ArchSpec
248
258
  # models.cannot_reference_constants "ActionController", "ActionView"
249
259
  #
250
260
  # Rule id: +constants.forbid+.
251
- def cannot_reference_constants(*constants)
252
- add_rule(Rules::CannotReferenceConstantsRule.new(name, constants))
261
+ def cannot_reference_constants(*constants, because: nil)
262
+ add_rule(Rules::CannotReferenceConstantsRule.new(name, constants), because: because)
253
263
  self
254
264
  end
255
265
 
@@ -263,8 +273,11 @@ module ArchSpec
263
273
  # +constants+ matches exact names, +namespace+ matches a name and its
264
274
  # children. Code inside the component may still reach its own internals.
265
275
  # Rule id: +dependencies.privacy+.
266
- def public_api(*patterns, constants: nil, namespace: nil)
267
- add_rule(Rules::PublicApiRule.new(name, files: patterns, constants: constants, namespaces: namespace))
276
+ def public_api(*patterns, constants: nil, namespace: nil, because: nil)
277
+ add_rule(
278
+ Rules::PublicApiRule.new(name, files: patterns, constants: constants, namespaces: namespace),
279
+ because: because
280
+ )
268
281
  self
269
282
  end
270
283
 
@@ -276,8 +289,8 @@ module ArchSpec
276
289
  # model_concerns.cannot_reference_includers
277
290
  #
278
291
  # Rule id: +concerns.independence+.
279
- def cannot_reference_includers
280
- add_rule(Rules::ConcernIndependenceRule.new(name))
292
+ def cannot_reference_includers(because: nil)
293
+ add_rule(Rules::ConcernIndependenceRule.new(name), because: because)
281
294
  self
282
295
  end
283
296
 
@@ -290,34 +303,48 @@ module ArchSpec
290
303
  #
291
304
  # Rule id: +components.empty+.
292
305
  def must_be_empty(because: nil)
293
- add_rule(Rules::MustBeEmptyRule.new(name, because: because))
306
+ add_rule(Rules::MustBeEmptyRule.new(name), because: because)
294
307
  self
295
308
  end
296
309
 
297
310
  # Requires every class in the component to implement all the named
298
- # instance methods. Methods inherited from resolvable superclasses or
299
- # mixins count.
311
+ # methods. Instance methods are checked by default; pass
312
+ # <tt>scope: :class</tt> for the class side. Methods inherited from
313
+ # resolvable superclasses or mixins count. Optional +arity:+ and
314
+ # +keywords:+ constraints check whether each method accepts that call.
300
315
  #
301
316
  # commands.must_implement :perform
317
+ # commands.must_implement :call, arity: 1, keywords: :actor
318
+ # jobs.must_implement :perform_later, scope: :class
302
319
  #
303
320
  # Rule id: +protocol.must_implement+.
304
- def must_implement(*methods)
321
+ def must_implement(*methods, scope: :instance, arity: nil, keywords: nil, because: nil)
305
322
  raise Error, 'must_implement requires at least one method' if methods.flatten.compact.empty?
306
323
 
307
324
  methods.each do |method_name|
308
- add_rule(Rules::MustImplementRule.new(name, method_name))
325
+ add_rule(
326
+ Rules::MustImplementRule.new(
327
+ name,
328
+ method_name,
329
+ scope: scope,
330
+ arity: arity,
331
+ keywords: keywords
332
+ ),
333
+ because: because
334
+ )
309
335
  end
310
336
  self
311
337
  end
312
338
 
313
339
  # Requires every class in the component to implement at least one of the
314
- # named instance methods. Useful when a protocol allows either name.
340
+ # named methods. Useful when a protocol allows either name. Pass
341
+ # <tt>scope: :class</tt> to check the class side.
315
342
  #
316
343
  # commands.must_implement_one_of :perform, :call
317
344
  #
318
345
  # Rule id: +protocol.must_implement_one_of+.
319
- def must_implement_one_of(*methods)
320
- add_rule(Rules::MustImplementOneOfRule.new(name, methods))
346
+ def must_implement_one_of(*methods, scope: :instance, because: nil)
347
+ add_rule(Rules::MustImplementOneOfRule.new(name, methods, scope: scope), because: because)
321
348
  self
322
349
  end
323
350
 
@@ -338,13 +365,17 @@ module ArchSpec
338
365
 
339
366
  private
340
367
 
341
- def add_rule(rule)
368
+ def add_rule(rule, because: nil)
369
+ rule = Rules.with_reason(rule, because)
342
370
  if rule.respond_to?(:merge_key)
343
371
  existing = definition.rules.find do |candidate|
344
372
  candidate.respond_to?(:merge_key) && candidate.merge_key == rule.merge_key
345
373
  end
346
374
 
347
- return existing.merge!(rule) if existing.respond_to?(:merge!)
375
+ if existing
376
+ Rules.with_reason(existing, rule.archspec_because) if rule.respond_to?(:archspec_because)
377
+ return existing.merge!(rule) if existing.respond_to?(:merge!)
378
+ end
348
379
  return existing if existing
349
380
  end
350
381
 
@@ -28,7 +28,11 @@ module ArchSpec
28
28
  print_parse_errors(output, style, file)
29
29
  print_component_reasons(output, style, graph.component_assignment_reasons_for_path(path))
30
30
  print_suppressions(output, style, file)
31
- print_facts(output, style, graph.edges.select { |edge| edge.from_path == path })
31
+ print_facts(output, style, graph.edges.select { |edge| edge.from_path == path }, label: 'outgoing facts')
32
+ names = graph.constants_for_path(path).map(&:name).to_set
33
+ incoming = graph.dependency_edges.select { |edge| names.include?(graph.resolve_edge_constant(edge)) }
34
+ print_facts(output, style, incoming, label: 'incoming dependencies')
35
+ print_census(output, style, graph.analysis_census(path: path))
32
36
  end
33
37
 
34
38
  def explain_constant(output, style, graph, subject)
@@ -40,14 +44,29 @@ module ArchSpec
40
44
  output.puts style.bold(constant.name)
41
45
  output.puts
42
46
  output.puts " #{style.note('kind:')} #{constant.kind}"
43
- output.puts " #{style.note('file:')} #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
47
+ relative_location = "#{constant.location.relative_path(graph.root)}:#{constant.location.line}"
48
+ output.puts " #{style.note('file:')} #{relative_location}"
44
49
  print_component_reasons(
45
50
  output, style,
46
51
  graph.component_assignment_reasons_for_constant(constant.name, path: constant.path)
47
52
  )
48
53
  output.puts " #{style.note('superclass:')} #{constant.superclass || '(none)'}"
54
+ ancestors, unresolved = graph.ancestor_names(constant.name)
55
+ resolved_ancestors = ancestors.empty? ? '(none)' : ancestors.to_a.join(', ')
56
+ output.puts " #{style.note('resolved ancestors:')} #{resolved_ancestors}"
57
+ unless unresolved.empty?
58
+ output.puts " #{style.note('unresolved ancestors:')} #{unresolved.to_a.sort.join(', ')}"
59
+ end
49
60
  output.puts " #{style.note('instance methods:')} #{constant.instance_methods.to_a.sort.join(', ')}"
50
61
  output.puts " #{style.note('class methods:')} #{constant.class_methods.to_a.sort.join(', ')}"
62
+ print_facts(
63
+ output,
64
+ style,
65
+ graph.edges.select { |edge| edge.from_constant == constant.name && edge.from_path == constant.path },
66
+ label: 'outgoing facts'
67
+ )
68
+ print_facts(output, style, graph.incoming_dependency_edges(constant.name), label: 'incoming dependencies')
69
+ print_census(output, style, graph.analysis_census(path: constant.path))
51
70
  end
52
71
  end
53
72
 
@@ -85,13 +104,13 @@ module ArchSpec
85
104
  end
86
105
  end
87
106
 
88
- def print_facts(output, style, facts)
107
+ def print_facts(output, style, facts, label:)
89
108
  if facts.empty?
90
- output.puts " #{style.note('outgoing facts:')} (none)"
109
+ output.puts " #{style.note("#{label}:")} (none)"
91
110
  return
92
111
  end
93
112
 
94
- output.puts " #{style.note('outgoing facts:')}"
113
+ output.puts " #{style.note("#{label}:")}"
95
114
  locations = facts.map { |edge| "#{edge.location.line}:#{edge.location.column}" }
96
115
  in_gutters(locations) do |gutter, index|
97
116
  edge = facts[index]
@@ -99,6 +118,15 @@ module ArchSpec
99
118
  end
100
119
  end
101
120
 
121
+ def print_census(output, style, census)
122
+ clauses = []
123
+ clauses << "#{census[:unresolved_constants]} unresolved constants" if census[:unresolved_constants].positive?
124
+ clauses << "#{census[:dynamic_features]} dynamic features" if census[:dynamic_features].positive?
125
+ clauses << "#{census[:unknown_receivers]} unknown receivers" if census[:unknown_receivers].positive?
126
+ census[:rubydex_diagnostics].each { |rule, count| clauses << "#{count} RubyDEX #{rule}" }
127
+ output.puts " #{style.note('analysis gaps:')} #{clauses.empty? ? '(none)' : clauses.join(', ')}"
128
+ end
129
+
102
130
  # Yields each label right-justified to the widest one, with the frame
103
131
  # gutter bar appended, so columns line up like the check output.
104
132
  def in_gutters(labels)
@@ -12,6 +12,7 @@ module ArchSpec
12
12
  files: graph.files.size,
13
13
  constants: graph.constants.size,
14
14
  facts: graph.edges.size,
15
+ analysis: graph.analysis_census,
15
16
  violations: diagnostics.map { |diagnostic| diagnostic.to_h(root: graph.root) }
16
17
  )
17
18
  end
@@ -28,6 +28,7 @@ module ArchSpec
28
28
  if diagnostics.empty?
29
29
  output.puts "ArchSpec passed: #{graph.files.size} files, #{graph.constants.size} constants, " \
30
30
  "#{graph.edges.size} facts checked."
31
+ print_census(output, graph)
31
32
  return
32
33
  end
33
34
 
@@ -40,13 +41,15 @@ module ArchSpec
40
41
 
41
42
  label = diagnostics.size == 1 ? 'architecture violation' : 'architecture violations'
42
43
  output.puts style.bold("#{diagnostics.size} #{label} found.")
44
+ print_census(output, graph)
43
45
  end
44
46
 
45
47
  def print_diagnostic(output, style, graph, diagnostic, sources)
46
48
  location = diagnostic.location
47
49
  relative = location.relative_path(graph.root)
48
50
 
49
- output.puts "#{style.severity('[error]')} #{style.bold(diagnostic.message)} #{style.faint("[#{diagnostic.rule}]")}"
51
+ header = "#{style.severity('[error]')} #{style.bold(diagnostic.message)} #{style.faint("[#{diagnostic.rule}]")}"
52
+ output.puts header
50
53
  output.puts
51
54
  output.puts "#{relative}:#{location.line}:#{location.column}"
52
55
  print_frame(output, style, location, sources[location.path])
@@ -55,6 +58,7 @@ module ArchSpec
55
58
  output.puts
56
59
  output.puts " #{style.note('note:')} #{note}"
57
60
  end
61
+ output.puts " #{style.note('reason:')} #{diagnostic.reason}" if diagnostic.reason
58
62
  output.puts
59
63
  end
60
64
 
@@ -107,6 +111,20 @@ module ArchSpec
107
111
  rescue SystemCallError
108
112
  []
109
113
  end
114
+
115
+ def print_census(output, graph)
116
+ census = graph.analysis_census
117
+ clauses = []
118
+ if census[:unresolved_constants].positive?
119
+ clauses << "#{census[:unresolved_constants]} unresolved constant references"
120
+ end
121
+ clauses << "#{census[:dynamic_features]} dynamic features" if census[:dynamic_features].positive?
122
+ clauses << "#{census[:unknown_receivers]} calls with unknown receivers" if census[:unknown_receivers].positive?
123
+ census[:rubydex_diagnostics].each do |rule, count|
124
+ clauses << "#{count} RubyDEX #{rule} #{count == 1 ? 'diagnostic' : 'diagnostics'}"
125
+ end
126
+ output.puts "Analysis gaps: #{clauses.join(', ')}." unless clauses.empty?
127
+ end
110
128
  end
111
129
  end
112
130
  end