archspec 0.4.0 → 1.0.0.rc1

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.
@@ -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,26 +18,27 @@ 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
30
29
  end
31
30
 
32
31
  class ConstantNode
33
- attr_reader :name, :kind, :path, :location, :instance_methods, :class_methods, :method_definitions, :mixins
32
+ attr_reader :name, :kind, :path, :location, :instance_methods, :class_methods, :method_definitions, :mixins,
33
+ :nesting
34
34
  attr_accessor :superclass
35
35
 
36
- def initialize(name:, kind:, path:, location:)
36
+ def initialize(name:, kind:, path:, location:, nesting: [])
37
37
  @name = name
38
38
  @kind = kind
39
39
  @path = path
40
40
  @location = location
41
+ @nesting = Array(nesting).dup.freeze
41
42
  @instance_methods = Set.new
42
43
  @class_methods = Set.new
43
44
  @method_definitions = []
@@ -56,22 +57,58 @@ module ArchSpec
56
57
  kind == :module
57
58
  end
58
59
 
59
- def add_instance_method(name, location:)
60
+ def add_instance_method(name, location:, visibility: :public)
60
61
  instance_methods.add(name.to_sym)
61
- method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location)
62
+ method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location, visibility)
62
63
  end
63
64
 
64
- def add_class_method(name, location:)
65
+ def add_class_method(name, location:, visibility: :public)
65
66
  class_methods.add(name.to_sym)
66
- method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location)
67
+ method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location, visibility)
67
68
  end
68
69
 
69
70
  def add_mixin(kind, name)
70
71
  mixins.fetch(kind).add(name)
71
72
  end
73
+
74
+ # Rewrites the visibility of already-recorded definitions, for the
75
+ # <tt>private :foo, :bar</tt> form that names methods defined earlier.
76
+ def set_visibility(name, scope, visibility)
77
+ name = name.to_sym
78
+ method_definitions.map! do |definition|
79
+ definition.name == name && definition.scope == scope ? definition.with(visibility: visibility) : definition
80
+ end
81
+ end
72
82
  end
73
83
 
74
- Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence, :receiver)
84
+ Edge = ValueObject.define(
85
+ :type,
86
+ :from_path,
87
+ :from_constant,
88
+ :to,
89
+ :location,
90
+ :confidence,
91
+ :receiver,
92
+ :lexical_nesting
93
+ ) do
94
+ VERBS = {
95
+ references_constant: 'references',
96
+ inherits_from: 'inherits from',
97
+ includes: 'includes',
98
+ prepends: 'prepends',
99
+ extends: 'extends',
100
+ calls_named_method: 'calls',
101
+ instantiates_and_invokes: 'instantiates and invokes',
102
+ requires: 'requires',
103
+ requires_relative: 'requires',
104
+ dynamic_feature: 'uses dynamic feature'
105
+ }.freeze
106
+
107
+ # The edge type as prose, for diagnostics and explain output.
108
+ def verb
109
+ VERBS.fetch(type, type.to_s.tr('_', ' '))
110
+ end
111
+ end
75
112
 
76
113
  class Component
77
114
  attr_reader :name, :files, :constants, :file_reasons, :constant_reasons
@@ -80,6 +117,7 @@ module ArchSpec
80
117
  @name = name.to_sym
81
118
  @files = Set.new
82
119
  @constants = Set.new
120
+ @constant_occurrences = Set.new
83
121
  @file_reasons = Hash.new { |hash, key| hash[key] = Set.new }
84
122
  @constant_reasons = Hash.new { |hash, key| hash[key] = Set.new }
85
123
  end
@@ -89,10 +127,17 @@ module ArchSpec
89
127
  file_reasons[path].add(reason) if reason
90
128
  end
91
129
 
92
- def add_constant(name, reason: nil)
130
+ def add_constant(name, path:, reason: nil)
93
131
  constants.add(name)
132
+ @constant_occurrences.add([name, path])
94
133
  constant_reasons[name].add(reason) if reason
95
134
  end
135
+
136
+ def includes_constant?(name, path: nil)
137
+ return constants.include?(name) unless path
138
+
139
+ @constant_occurrences.include?([name, path])
140
+ end
96
141
  end
97
142
 
98
143
  class Graph
@@ -117,29 +162,30 @@ module ArchSpec
117
162
  @components = {}
118
163
  end
119
164
 
120
- def add_file(path:, expected_constant:, parse_errors:, suppressions: [])
165
+ def add_file(path:, parse_errors:, suppressions: [])
121
166
  files[path] = SourceFile.new(
122
167
  root: root,
123
168
  path: path,
124
- expected_constant: expected_constant,
125
169
  parse_errors: parse_errors,
126
170
  suppressions: suppressions
127
171
  )
128
172
  end
129
173
 
130
- def add_constant(name:, kind:, path:, location:)
174
+ def add_constant(name:, kind:, path:, location:, nesting: [])
131
175
  normalized = normalize_constant(name)
132
176
  existing = @constants_by_name[normalized].find { |constant| constant.path == path && constant.kind == kind }
133
177
  return existing if existing
134
178
 
135
- constant = ConstantNode.new(name: normalized, kind: kind, path: path, location: location)
179
+ constant = ConstantNode.new(name: normalized, kind: kind, path: path, location: location, nesting: nesting)
136
180
  constants << constant
137
181
  @constants_by_name[normalized] << constant
138
182
  constant
139
183
  end
140
184
 
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)
185
+ def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil,
186
+ lexical_nesting: nil)
187
+ nesting = lexical_nesting&.map { |name| normalize_constant(name) }&.freeze
188
+ edges << Edge.new(type, from_path, from_constant, to.to_s, location, confidence, receiver, nesting)
143
189
  end
144
190
 
145
191
  def constants_named(name)
@@ -154,7 +200,13 @@ module ArchSpec
154
200
  component = components[name.to_sym]
155
201
  return [] unless component
156
202
 
157
- component.constants.flat_map { |constant_name| constants_named(constant_name) }.flat_map(&:method_definitions)
203
+ constants_for_component(name).flat_map(&:method_definitions)
204
+ end
205
+
206
+ # Every method definition in the graph, across all constants. Used by
207
+ # project-wide naming rules that are not scoped to one component.
208
+ def method_definitions
209
+ constants.flat_map(&:method_definitions)
158
210
  end
159
211
 
160
212
  def assign_components(component_specs)
@@ -162,18 +214,22 @@ module ArchSpec
162
214
 
163
215
  component_specs.each do |spec|
164
216
  component = Component.new(spec.name)
217
+ files_matched_by_pattern = Set.new
165
218
 
166
219
  spec.file_patterns.each do |pattern|
167
- each_matching_file(pattern) { |path| component.add_file(path, reason: "matched file pattern #{pattern}") }
220
+ each_matching_file(pattern) do |path|
221
+ files_matched_by_pattern.add(path)
222
+ component.add_file(path, reason: "matched file pattern #{pattern}")
223
+ end
168
224
  end
169
225
 
170
226
  constants.each do |constant|
171
- matched_file = component.files.include?(constant.path)
227
+ matched_file = files_matched_by_pattern.include?(constant.path)
172
228
  matched_constant = spec.matches_constant?(constant.name)
173
229
  next unless matched_file || matched_constant
174
230
 
175
231
  component.add_file(constant.path, reason: "defines #{constant.name}") if matched_constant
176
- component.add_constant(constant.name,
232
+ component.add_constant(constant.name, path: constant.path,
177
233
  reason: matched_file ? 'defined in matched file' : 'matched namespace/constant selector')
178
234
  end
179
235
 
@@ -187,14 +243,31 @@ module ArchSpec
187
243
  end
188
244
  end
189
245
 
190
- def component_names_for_constant(name)
246
+ def component_names_for_constant(name, path: nil)
191
247
  normalized = normalize_constant(name)
192
248
 
193
249
  components.values.each_with_object(Set.new) do |component, names|
194
- names.add(component.name) if component.constants.include?(normalized)
250
+ names.add(component.name) if component.includes_constant?(normalized, path: path)
195
251
  end
196
252
  end
197
253
 
254
+ # The edge's source as prose: its constant when known, otherwise the
255
+ # root-relative path of the file. Diagnostics use this so evidence never
256
+ # embeds an absolute path, which would make todo fingerprints
257
+ # machine-specific.
258
+ def edge_source_name(edge)
259
+ edge.from_constant || files[edge.from_path]&.relative_path || edge.from_path
260
+ end
261
+
262
+ # Components that own the source of an edge. Constant and namespace
263
+ # selectors stay precise when a file also defines unrelated constants;
264
+ # top-level edges fall back to the file assignment.
265
+ def source_components_for(edge)
266
+ return component_names_for_path(edge.from_path) unless edge.from_constant
267
+
268
+ component_names_for_constant(edge.from_constant, path: edge.from_path)
269
+ end
270
+
198
271
  def dependency_edges
199
272
  edges.select { |edge| DEPENDENCY_EDGE_TYPES.include?(edge.type) }
200
273
  end
@@ -202,31 +275,30 @@ module ArchSpec
202
275
  def target_components_for(edge)
203
276
  return Set.new unless DEPENDENCY_EDGE_TYPES.include?(edge.type)
204
277
 
205
- resolved = resolve_constant_reference(edge.to, edge.from_constant)
278
+ resolved = resolve_edge_constant(edge)
206
279
  constants_named(resolved).each_with_object(Set.new) do |constant, names|
207
- names.merge(component_names_for_path(constant.path))
208
- names.merge(component_names_for_constant(constant.name))
280
+ names.merge(component_names_for_constant(resolved, path: constant.path))
209
281
  end
210
282
  end
211
283
 
212
- def resolve_constant_reference(name, from_constant)
284
+ def resolve_constant_reference(name, from_constant = nil, lexical_nesting: nil)
285
+ absolute = name.to_s.start_with?('::')
213
286
  normalized = normalize_constant(name)
214
- candidates = []
215
-
216
- if from_constant
217
- namespace = normalize_constant(from_constant).split('::')
218
- namespace.pop
219
-
220
- until namespace.empty?
221
- candidates << "#{namespace.join('::')}::#{normalized}"
222
- namespace.pop
223
- end
224
- end
287
+ return normalized if absolute
225
288
 
289
+ scopes = lexical_nesting || inferred_nesting(from_constant)
290
+ candidates = scopes.map { |scope| "#{normalize_constant(scope)}::#{normalized}" }
226
291
  candidates << normalized
227
292
  candidates.find { |candidate| constants_named(candidate).any? } || normalized
228
293
  end
229
294
 
295
+ # Resolves an edge with the lexical nesting captured where the reference
296
+ # appeared. This matters for compact class declarations and superclass
297
+ # expressions, where inferring scope from the finished class name is wrong.
298
+ def resolve_edge_constant(edge)
299
+ resolve_constant_reference(edge.to, edge.from_constant, lexical_nesting: edge.lexical_nesting)
300
+ end
301
+
230
302
  # Instance methods a constant responds to, walking resolvable superclasses
231
303
  # and include/prepend mixins. Returns [methods, unresolved ancestor names];
232
304
  # a non-empty second element means the answer is incomplete.
@@ -246,11 +318,21 @@ module ArchSpec
246
318
  nodes.each do |node|
247
319
  methods.merge(node.instance_methods)
248
320
 
249
- ancestors = node.mixins[:include].to_a + node.mixins[:prepend].to_a
250
- ancestors << node.superclass if node.superclass
321
+ mixins = node.mixins[:include].to_a + node.mixins[:prepend].to_a
322
+
323
+ mixins.each do |ancestor|
324
+ resolved_name = resolve_constant_reference(
325
+ ancestor,
326
+ node.name,
327
+ lexical_nesting: [node.name] + node.nesting
328
+ )
329
+ ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
330
+ methods.merge(ancestor_methods)
331
+ unresolved.merge(ancestor_unresolved)
332
+ end
251
333
 
252
- ancestors.each do |ancestor|
253
- resolved_name = resolve_constant_reference(ancestor, node.name)
334
+ if node.superclass
335
+ resolved_name = resolve_constant_reference(node.superclass, node.name, lexical_nesting: node.nesting)
254
336
  ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
255
337
  methods.merge(ancestor_methods)
256
338
  unresolved.merge(ancestor_unresolved)
@@ -265,7 +347,7 @@ module ArchSpec
265
347
  pairs = Set.new
266
348
 
267
349
  dependency_edges.each do |edge|
268
- source_components = component_names_for_path(edge.from_path)
350
+ source_components = source_components_for(edge)
269
351
  source_components &= allowed_sources unless allowed_sources.empty?
270
352
  next if source_components.empty?
271
353
 
@@ -287,16 +369,23 @@ module ArchSpec
287
369
  end
288
370
  end
289
371
 
290
- def component_assignment_reasons_for_constant(name)
372
+ def component_assignment_reasons_for_constant(name, path: nil)
291
373
  normalized = normalize_constant(name)
292
374
 
293
375
  components.values.each_with_object({}) do |component, reasons|
294
- next unless component.constants.include?(normalized)
376
+ next unless component.includes_constant?(normalized, path: path)
295
377
 
296
378
  reasons[component.name] = component.constant_reasons[normalized].to_a.sort
297
379
  end
298
380
  end
299
381
 
382
+ def constants_for_component(name)
383
+ component = components[name.to_sym]
384
+ return [] unless component
385
+
386
+ constants.select { |constant| component.includes_constant?(constant.name, path: constant.path) }
387
+ end
388
+
300
389
  def suppressed?(diagnostic)
301
390
  files[diagnostic.location.path]&.suppressions&.any? { |suppression| suppression.matches?(diagnostic) }
302
391
  end
@@ -314,5 +403,12 @@ module ArchSpec
314
403
  def normalize_constant(value)
315
404
  value.to_s.sub(/\A::/, '')
316
405
  end
406
+
407
+ def inferred_nesting(from_constant)
408
+ return [] unless from_constant
409
+
410
+ parts = normalize_constant(from_constant).split('::')
411
+ parts.length.downto(1).map { |length| parts.first(length).join('::') }
412
+ end
317
413
  end
318
414
  end
@@ -30,7 +30,7 @@ module ArchSpec
30
30
  Diagnostic.new(
31
31
  rule: id,
32
32
  message: because ? "#{source} must stay empty: #{because}" : "#{source} must stay empty",
33
- location: SourceLocation.new(path, 1, 1),
33
+ location: SourceLocation.point(path, 1, 1),
34
34
  evidence: "#{relative} belongs to #{source}"
35
35
  )
36
36
  end
@@ -34,7 +34,7 @@ module ArchSpec
34
34
  consumers = includers[edge.from_constant]
35
35
  next if consumers.empty?
36
36
 
37
- target = graph.resolve_constant_reference(edge.to, edge.from_constant)
37
+ target = graph.resolve_edge_constant(edge)
38
38
  includer = consumers.find { |name| target == name || target.start_with?("#{name}::") }
39
39
  next unless includer
40
40
 
@@ -42,7 +42,7 @@ module ArchSpec
42
42
  rule: id,
43
43
  message: "#{edge.from_constant} must not reference its includer #{includer}",
44
44
  location: edge.location,
45
- evidence: "#{edge.from_constant} #{edge.type} #{target}"
45
+ evidence: "#{edge.from_constant} #{edge.verb} #{target}"
46
46
  )
47
47
  end
48
48
  end
@@ -56,7 +56,7 @@ module ArchSpec
56
56
  graph.edges.each do |edge|
57
57
  next unless MIXIN_TYPES.include?(edge.type) && edge.from_constant
58
58
 
59
- mod = graph.resolve_constant_reference(edge.to, edge.from_constant)
59
+ mod = graph.resolve_edge_constant(edge)
60
60
  map[mod].add(edge.from_constant) unless mod == edge.from_constant
61
61
  end
62
62
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module ArchSpec
4
4
  module Rules
5
- # Backs ArchSpec::DSL::Context#no_cycles!. Flags dependency cycles between
5
+ # Backs ArchSpec::DSL::Context#no_cycles. Flags dependency cycles between
6
6
  # components, reporting each cycle once in a canonical order.
7
7
  class NoCyclesRule
8
8
  attr_reader :components
@@ -17,7 +17,7 @@ module ArchSpec
17
17
 
18
18
  def evaluate(graph)
19
19
  cycles(graph).map do |cycle|
20
- location = first_location_for_cycle(graph, cycle) || SourceLocation.new(graph.root, 1, 1)
20
+ location = first_location_for_cycle(graph, cycle) || SourceLocation.point(graph.root, 1, 1)
21
21
  Diagnostic.new(
22
22
  rule: id,
23
23
  message: "component dependency cycle: #{cycle.join(' -> ')}",
@@ -74,7 +74,7 @@ module ArchSpec
74
74
  def first_location_for_cycle(graph, cycle)
75
75
  source, target = cycle
76
76
  graph.dependency_edges.find do |edge|
77
- graph.component_names_for_path(edge.from_path).include?(source) &&
77
+ graph.source_components_for(edge).include?(source) &&
78
78
  graph.target_components_for(edge).include?(target)
79
79
  end&.location
80
80
  end
@@ -29,19 +29,11 @@ module ArchSpec
29
29
  private
30
30
 
31
31
  def relevant_edges(graph)
32
- graph.dependency_edges.select { |edge| graph.component_names_for_path(edge.from_path).include?(source) }
33
- end
34
-
35
- def target_components(graph, edge)
36
- graph.target_components_for(edge)
37
- end
38
-
39
- def edge_target(edge)
40
- edge.to
32
+ graph.dependency_edges.select { |edge| graph.source_components_for(edge).include?(source) }
41
33
  end
42
34
  end
43
35
 
44
- # Backs ArchSpec::DSL::ComponentProxy#can_use. Flags references from the
36
+ # Backs ArchSpec::DSL::ComponentProxy#can_only_use. Flags references from the
45
37
  # source to any component outside its allowlist.
46
38
  class AllowDependenciesRule < DependencyRule
47
39
  def id
@@ -50,14 +42,14 @@ module ArchSpec
50
42
 
51
43
  def evaluate(graph)
52
44
  relevant_edges(graph).flat_map do |edge|
53
- target_components(graph, edge).filter_map do |target|
45
+ graph.target_components_for(edge).filter_map do |target|
54
46
  next if target == source || targets.include?(target)
55
47
 
56
48
  Diagnostic.new(
57
49
  rule: id,
58
50
  message: "#{source} may not depend on #{target}",
59
51
  location: edge.location,
60
- evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge_target(edge)}"
52
+ evidence: "#{graph.edge_source_name(edge)} #{edge.verb} #{edge.to}"
61
53
  )
62
54
  end
63
55
  end
@@ -73,14 +65,14 @@ module ArchSpec
73
65
 
74
66
  def evaluate(graph)
75
67
  relevant_edges(graph).flat_map do |edge|
76
- forbidden = target_components(graph, edge) & targets
68
+ forbidden = graph.target_components_for(edge) & targets
77
69
 
78
70
  forbidden.map do |target|
79
71
  Diagnostic.new(
80
72
  rule: id,
81
73
  message: "#{source} must not depend on #{target}",
82
74
  location: edge.location,
83
- evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge_target(edge)}"
75
+ evidence: "#{graph.edge_source_name(edge)} #{edge.verb} #{edge.to}"
84
76
  )
85
77
  end
86
78
  end
@@ -115,7 +107,7 @@ module ArchSpec
115
107
  graph.dependency_edges.flat_map do |edge|
116
108
  next [] unless graph.target_components_for(edge).include?(source)
117
109
 
118
- offenders = graph.component_names_for_path(edge.from_path).reject do |component|
110
+ offenders = graph.source_components_for(edge).reject do |component|
119
111
  component == source || consumers.include?(component)
120
112
  end
121
113
 
@@ -124,7 +116,7 @@ module ArchSpec
124
116
  rule: id,
125
117
  message: message_for(offender),
126
118
  location: edge.location,
127
- evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge.to}"
119
+ evidence: "#{graph.edge_source_name(edge)} #{edge.verb} #{edge.to}"
128
120
  )
129
121
  end
130
122
  end
@@ -164,16 +156,16 @@ module ArchSpec
164
156
 
165
157
  def evaluate(graph)
166
158
  graph.dependency_edges.filter_map do |edge|
167
- next unless graph.component_names_for_path(edge.from_path).include?(source)
159
+ next unless graph.source_components_for(edge).include?(source)
168
160
 
169
- referenced = graph.resolve_constant_reference(edge.to, edge.from_constant)
161
+ referenced = graph.resolve_edge_constant(edge)
170
162
  next unless constants.any? { |constant| referenced == constant || referenced.start_with?("#{constant}::") }
171
163
 
172
164
  Diagnostic.new(
173
165
  rule: id,
174
166
  message: "#{source} must not reference #{referenced}",
175
167
  location: edge.location,
176
- evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge.to}"
168
+ evidence: "#{graph.edge_source_name(edge)} #{edge.verb} #{edge.to}"
177
169
  )
178
170
  end
179
171
  end