archspec 0.5.0 → 1.0.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.
@@ -29,14 +29,16 @@ module ArchSpec
29
29
  end
30
30
 
31
31
  class ConstantNode
32
- 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
33
34
  attr_accessor :superclass
34
35
 
35
- def initialize(name:, kind:, path:, location:)
36
+ def initialize(name:, kind:, path:, location:, nesting: [])
36
37
  @name = name
37
38
  @kind = kind
38
39
  @path = path
39
40
  @location = location
41
+ @nesting = Array(nesting).dup.freeze
40
42
  @instance_methods = Set.new
41
43
  @class_methods = Set.new
42
44
  @method_definitions = []
@@ -79,7 +81,34 @@ module ArchSpec
79
81
  end
80
82
  end
81
83
 
82
- 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
83
112
 
84
113
  class Component
85
114
  attr_reader :name, :files, :constants, :file_reasons, :constant_reasons
@@ -88,6 +117,7 @@ module ArchSpec
88
117
  @name = name.to_sym
89
118
  @files = Set.new
90
119
  @constants = Set.new
120
+ @constant_occurrences = Set.new
91
121
  @file_reasons = Hash.new { |hash, key| hash[key] = Set.new }
92
122
  @constant_reasons = Hash.new { |hash, key| hash[key] = Set.new }
93
123
  end
@@ -97,10 +127,17 @@ module ArchSpec
97
127
  file_reasons[path].add(reason) if reason
98
128
  end
99
129
 
100
- def add_constant(name, reason: nil)
130
+ def add_constant(name, path:, reason: nil)
101
131
  constants.add(name)
132
+ @constant_occurrences.add([name, path])
102
133
  constant_reasons[name].add(reason) if reason
103
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
104
141
  end
105
142
 
106
143
  class Graph
@@ -134,19 +171,21 @@ module ArchSpec
134
171
  )
135
172
  end
136
173
 
137
- def add_constant(name:, kind:, path:, location:)
174
+ def add_constant(name:, kind:, path:, location:, nesting: [])
138
175
  normalized = normalize_constant(name)
139
176
  existing = @constants_by_name[normalized].find { |constant| constant.path == path && constant.kind == kind }
140
177
  return existing if existing
141
178
 
142
- 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)
143
180
  constants << constant
144
181
  @constants_by_name[normalized] << constant
145
182
  constant
146
183
  end
147
184
 
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)
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)
150
189
  end
151
190
 
152
191
  def constants_named(name)
@@ -161,7 +200,7 @@ module ArchSpec
161
200
  component = components[name.to_sym]
162
201
  return [] unless component
163
202
 
164
- component.constants.flat_map { |constant_name| constants_named(constant_name) }.flat_map(&:method_definitions)
203
+ constants_for_component(name).flat_map(&:method_definitions)
165
204
  end
166
205
 
167
206
  # Every method definition in the graph, across all constants. Used by
@@ -175,18 +214,22 @@ module ArchSpec
175
214
 
176
215
  component_specs.each do |spec|
177
216
  component = Component.new(spec.name)
217
+ files_matched_by_pattern = Set.new
178
218
 
179
219
  spec.file_patterns.each do |pattern|
180
- 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
181
224
  end
182
225
 
183
226
  constants.each do |constant|
184
- matched_file = component.files.include?(constant.path)
227
+ matched_file = files_matched_by_pattern.include?(constant.path)
185
228
  matched_constant = spec.matches_constant?(constant.name)
186
229
  next unless matched_file || matched_constant
187
230
 
188
231
  component.add_file(constant.path, reason: "defines #{constant.name}") if matched_constant
189
- component.add_constant(constant.name,
232
+ component.add_constant(constant.name, path: constant.path,
190
233
  reason: matched_file ? 'defined in matched file' : 'matched namespace/constant selector')
191
234
  end
192
235
 
@@ -200,14 +243,31 @@ module ArchSpec
200
243
  end
201
244
  end
202
245
 
203
- def component_names_for_constant(name)
246
+ def component_names_for_constant(name, path: nil)
204
247
  normalized = normalize_constant(name)
205
248
 
206
249
  components.values.each_with_object(Set.new) do |component, names|
207
- names.add(component.name) if component.constants.include?(normalized)
250
+ names.add(component.name) if component.includes_constant?(normalized, path: path)
208
251
  end
209
252
  end
210
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
+
211
271
  def dependency_edges
212
272
  edges.select { |edge| DEPENDENCY_EDGE_TYPES.include?(edge.type) }
213
273
  end
@@ -215,31 +275,30 @@ module ArchSpec
215
275
  def target_components_for(edge)
216
276
  return Set.new unless DEPENDENCY_EDGE_TYPES.include?(edge.type)
217
277
 
218
- resolved = resolve_constant_reference(edge.to, edge.from_constant)
278
+ resolved = resolve_edge_constant(edge)
219
279
  constants_named(resolved).each_with_object(Set.new) do |constant, names|
220
- names.merge(component_names_for_path(constant.path))
221
- names.merge(component_names_for_constant(constant.name))
280
+ names.merge(component_names_for_constant(resolved, path: constant.path))
222
281
  end
223
282
  end
224
283
 
225
- 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?('::')
226
286
  normalized = normalize_constant(name)
227
- candidates = []
228
-
229
- if from_constant
230
- namespace = normalize_constant(from_constant).split('::')
231
- namespace.pop
232
-
233
- until namespace.empty?
234
- candidates << "#{namespace.join('::')}::#{normalized}"
235
- namespace.pop
236
- end
237
- end
287
+ return normalized if absolute
238
288
 
289
+ scopes = lexical_nesting || inferred_nesting(from_constant)
290
+ candidates = scopes.map { |scope| "#{normalize_constant(scope)}::#{normalized}" }
239
291
  candidates << normalized
240
292
  candidates.find { |candidate| constants_named(candidate).any? } || normalized
241
293
  end
242
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
+
243
302
  # Instance methods a constant responds to, walking resolvable superclasses
244
303
  # and include/prepend mixins. Returns [methods, unresolved ancestor names];
245
304
  # a non-empty second element means the answer is incomplete.
@@ -259,11 +318,21 @@ module ArchSpec
259
318
  nodes.each do |node|
260
319
  methods.merge(node.instance_methods)
261
320
 
262
- ancestors = node.mixins[:include].to_a + node.mixins[:prepend].to_a
263
- 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
264
333
 
265
- ancestors.each do |ancestor|
266
- 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)
267
336
  ancestor_methods, ancestor_unresolved = effective_instance_methods(resolved_name, visited)
268
337
  methods.merge(ancestor_methods)
269
338
  unresolved.merge(ancestor_unresolved)
@@ -278,7 +347,7 @@ module ArchSpec
278
347
  pairs = Set.new
279
348
 
280
349
  dependency_edges.each do |edge|
281
- source_components = component_names_for_path(edge.from_path)
350
+ source_components = source_components_for(edge)
282
351
  source_components &= allowed_sources unless allowed_sources.empty?
283
352
  next if source_components.empty?
284
353
 
@@ -300,16 +369,23 @@ module ArchSpec
300
369
  end
301
370
  end
302
371
 
303
- def component_assignment_reasons_for_constant(name)
372
+ def component_assignment_reasons_for_constant(name, path: nil)
304
373
  normalized = normalize_constant(name)
305
374
 
306
375
  components.values.each_with_object({}) do |component, reasons|
307
- next unless component.constants.include?(normalized)
376
+ next unless component.includes_constant?(normalized, path: path)
308
377
 
309
378
  reasons[component.name] = component.constant_reasons[normalized].to_a.sort
310
379
  end
311
380
  end
312
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
+
313
389
  def suppressed?(diagnostic)
314
390
  files[diagnostic.location.path]&.suppressions&.any? { |suppression| suppression.matches?(diagnostic) }
315
391
  end
@@ -327,5 +403,12 @@ module ArchSpec
327
403
  def normalize_constant(value)
328
404
  value.to_s.sub(/\A::/, '')
329
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
330
413
  end
331
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
 
@@ -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,15 +29,7 @@ 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
 
@@ -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
@@ -4,15 +4,21 @@ require 'set'
4
4
 
5
5
  module ArchSpec
6
6
  module Rules
7
- # Backs the +methods.matching(...)+ DSL. One rule pairs a selector (which
7
+ # Backs the +method_names.matching(...)+ DSL. One rule pairs a selector (which
8
8
  # methods it judges) with a constraint (what must hold of them). It judges a
9
9
  # component's defined, public methods in the requested scope, or every such
10
10
  # method in the project when +source+ is nil. Every finding is name-based and
11
11
  # exact, so confidence is always +:high+.
12
12
  class NamingRule
13
+ VALID_SCOPES = %i[instance class].freeze
14
+
13
15
  attr_reader :source, :selector, :scope, :except
14
16
 
15
17
  def initialize(source:, selector:, constraint:, scope: :instance, except: [])
18
+ unless VALID_SCOPES.include?(scope)
19
+ raise Error, "method_names scope: must be :instance or :class, got #{scope.inspect}"
20
+ end
21
+
16
22
  @source = source&.to_sym
17
23
  @selector = selector
18
24
  @constraint = constraint
@@ -52,6 +58,8 @@ module ArchSpec
52
58
  attr_reader :regex
53
59
 
54
60
  def initialize(regex)
61
+ raise Error, "method_names matching expects a Regexp, got #{regex.inspect}" unless regex.is_a?(Regexp)
62
+
55
63
  @regex = regex
56
64
  end
57
65
 
@@ -99,6 +107,11 @@ module ArchSpec
99
107
  # <tt>requires("without_%{base}")</tt>. Rule id +naming.requires+.
100
108
  class Requires
101
109
  def initialize(template, on: nil, scope: :instance, because: nil)
110
+ unless NamingRule::VALID_SCOPES.include?(scope)
111
+ raise Error, "requires scope: must be :instance or :class, got #{scope.inspect}"
112
+ end
113
+ raise Error, "requires expects a String template, got #{template.inspect}" unless template.is_a?(String)
114
+
102
115
  @template = template
103
116
  @on = on
104
117
  @target_scope = scope
@@ -155,7 +168,7 @@ module ArchSpec
155
168
  end
156
169
  end
157
170
 
158
- # Returned by ArchSpec::DSL::ComponentProxy#methods. Starts a selector.
171
+ # Returned by ArchSpec::DSL::ComponentProxy#method_names. Starts a selector.
159
172
  class Builder
160
173
  def initialize(component, scope: :instance)
161
174
  @component = component
@@ -181,6 +194,7 @@ module ArchSpec
181
194
  end
182
195
 
183
196
  def requires(template, on: nil, scope: :instance, except: [], because: nil)
197
+ validate_template!(template)
184
198
  add(Requires.new(template, on: component_name(on), scope: scope, because: because), except)
185
199
  end
186
200
 
@@ -201,7 +215,21 @@ module ArchSpec
201
215
  def component_name(target)
202
216
  return if target.nil?
203
217
 
204
- target.respond_to?(:name) ? target.name : target.to_sym
218
+ name = target.respond_to?(:name) ? target.name : target.to_sym
219
+ unless @component.definition.component?(name)
220
+ raise Error, "#{@component.name}.method_names.requires references unknown component: #{name}"
221
+ end
222
+
223
+ name
224
+ end
225
+
226
+ def validate_template!(template)
227
+ return unless template.is_a?(String)
228
+
229
+ captures = @selector.regex.names.to_h { |name| [name.to_sym, name] }
230
+ template % captures
231
+ rescue KeyError, ArgumentError => e
232
+ raise Error, "invalid requires template #{template.inspect}: #{e.message}"
205
233
  end
206
234
  end
207
235
  end
@@ -36,9 +36,9 @@ module ArchSpec
36
36
  public_names = public_constant_names(graph)
37
37
 
38
38
  graph.dependency_edges.filter_map do |edge|
39
- next if component.files.include?(edge.from_path)
39
+ next if graph.source_components_for(edge).include?(source)
40
40
 
41
- resolved = graph.resolve_constant_reference(edge.to, edge.from_constant)
41
+ resolved = graph.resolve_edge_constant(edge)
42
42
  next unless graph.component_names_for_constant(resolved).include?(source)
43
43
  next if public?(resolved, public_names)
44
44
 
@@ -46,7 +46,7 @@ module ArchSpec
46
46
  rule: id,
47
47
  message: "#{resolved} is private to #{source}",
48
48
  location: edge.location,
49
- evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{resolved}"
49
+ evidence: "#{graph.edge_source_name(edge)} #{edge.verb} #{resolved}"
50
50
  )
51
51
  end
52
52
  end
@@ -35,14 +35,14 @@ module ArchSpec
35
35
  next unless edge.type == :calls_named_method
36
36
  next unless method_names.include?(edge.to.to_sym)
37
37
  next if receiver == :none && edge.receiver != :none
38
- next unless graph.component_names_for_path(edge.from_path).include?(source)
38
+ next unless graph.source_components_for(edge).include?(source)
39
39
  next if own_method_call?(graph, edge)
40
40
 
41
41
  Diagnostic.new(
42
42
  rule: id,
43
43
  message: "#{source} must not call ##{edge.to}",
44
44
  location: edge.location,
45
- evidence: "#{edge.from_constant || edge.from_path} calls #{edge.to}"
45
+ evidence: "#{graph.edge_source_name(edge)} calls #{edge.to}"
46
46
  )
47
47
  end
48
48
  end
@@ -107,7 +107,10 @@ module ArchSpec
107
107
 
108
108
  def initialize(source, method_names)
109
109
  @source = source.to_sym
110
- @method_names = Array(method_names).flatten.map(&:to_sym)
110
+ names = Array(method_names).flatten.compact
111
+ raise Error, 'must_implement_one_of requires at least one method' if names.empty?
112
+
113
+ @method_names = names.map(&:to_sym)
111
114
  end
112
115
 
113
116
  def merge_key
@@ -154,7 +157,7 @@ module ArchSpec
154
157
  component = graph.components[source]
155
158
  return [] unless component
156
159
 
157
- component.constants.flat_map { |name| graph.constants_named(name) }.select(&:class?).uniq(&:name)
160
+ graph.constants_for_component(source).select(&:class?).uniq(&:name)
158
161
  end
159
162
 
160
163
  def for(constant, methods, unresolved)
@@ -222,13 +225,13 @@ module ArchSpec
222
225
  def evaluate(graph)
223
226
  graph.edges.filter_map do |edge|
224
227
  next unless edge.type == :instantiates_and_invokes
225
- next unless graph.component_names_for_path(edge.from_path).include?(source)
228
+ next unless graph.source_components_for(edge).include?(source)
226
229
 
227
230
  Diagnostic.new(
228
231
  rule: id,
229
232
  message: "#{source} must not instantiate and immediately invoke #{edge.to}",
230
233
  location: edge.location,
231
- evidence: "#{edge.from_constant || edge.from_path} uses #{edge.to}"
234
+ evidence: "#{graph.edge_source_name(edge)} uses #{edge.to}"
232
235
  )
233
236
  end
234
237
  end
@@ -5,9 +5,15 @@ require 'pathname'
5
5
  require_relative 'value_object'
6
6
 
7
7
  module ArchSpec
8
- SourceLocation = ValueObject.define(:path, :line, :column) do
8
+ SourceLocation = ValueObject.define(:path, :line, :column, :end_line, :end_column) do
9
9
  def self.from_prism(path, location)
10
- new(path, location.start_line, location.start_column + 1)
10
+ new(path, location.start_line, location.start_column + 1, location.end_line, location.end_column + 1)
11
+ end
12
+
13
+ # A zero-width location for diagnostics that point at a file rather than a
14
+ # span of code.
15
+ def self.point(path, line, column)
16
+ new(path, line, column, line, column)
11
17
  end
12
18
 
13
19
  def relative_path(root)
data/lib/archspec/todo.rb CHANGED
@@ -16,11 +16,24 @@ module ArchSpec
16
16
  return empty(root: root) unless path && File.exist?(path)
17
17
 
18
18
  document = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
19
- ids = Array(document['violations']).filter_map do |entry|
20
- entry.is_a?(Hash) ? entry['id'] : entry
19
+ unless document.is_a?(Hash) && (document['violations'].nil? || document['violations'].is_a?(Array))
20
+ raise Error, "invalid todo file #{path}: expected a violations list"
21
+ end
22
+
23
+ ids = Array(document['violations']).map.with_index do |entry, index|
24
+ id = entry.is_a?(Hash) ? entry['id'] : entry
25
+ unless id.is_a?(String) && !id.empty?
26
+ raise Error, "invalid todo file #{path}: violation #{index + 1} has no id"
27
+ end
28
+
29
+ id
21
30
  end
22
31
 
23
32
  new(ids.to_set, root: root)
33
+ rescue Error
34
+ raise
35
+ rescue Psych::Exception, SystemCallError => e
36
+ raise Error, "could not load todo file #{path}: #{e.message}"
24
37
  end
25
38
 
26
39
  def self.write(path, diagnostics, root:)
@@ -37,6 +50,8 @@ module ArchSpec
37
50
  }
38
51
 
39
52
  File.write(path, payload.to_yaml)
53
+ rescue SystemCallError => e
54
+ raise Error, "could not write todo file #{path}: #{e.message}"
40
55
  end
41
56
 
42
57
  def initialize(ids, root:)
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
+ # A frozen value type with positional or keyword construction and #with,
5
+ # mirroring Ruby 3.2's Data.define. Deliberately hand-rolled: the gem
6
+ # supports Ruby 3.1, where Data does not exist. Do not replace this with
7
+ # Data until the required Ruby version reaches 3.2.
4
8
  module ValueObject
5
9
  def self.define(*members, &block)
6
10
  klass = Struct.new(*members) do
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
- VERSION = '0.5.0'
4
+ VERSION = '1.0.0'
5
5
  end