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.
- checksums.yaml +4 -4
- data/README.md +26 -8
- data/lib/archspec/analyzer.rb +97 -53
- data/lib/archspec/architectures.rb +88 -84
- data/lib/archspec/cli.rb +114 -34
- data/lib/archspec/diagnostic.rb +2 -0
- data/lib/archspec/dsl.rb +16 -0
- data/lib/archspec/error.rb +7 -0
- data/lib/archspec/evaluator.rb +6 -8
- data/lib/archspec/formatters/explanation.rb +68 -38
- data/lib/archspec/formatters/style.rb +39 -0
- data/lib/archspec/formatters/text.rb +92 -8
- data/lib/archspec/model.rb +119 -36
- data/lib/archspec/rules/component_rules.rb +1 -1
- data/lib/archspec/rules/concern_rules.rb +3 -3
- data/lib/archspec/rules/cycle_rule.rb +2 -2
- data/lib/archspec/rules/dependency_rules.rb +10 -18
- data/lib/archspec/rules/naming_rules.rb +31 -3
- data/lib/archspec/rules/privacy_rule.rb +3 -3
- data/lib/archspec/rules/protocol_rules.rb +9 -6
- data/lib/archspec/source_location.rb +8 -2
- data/lib/archspec/todo.rb +17 -2
- data/lib/archspec/value_object.rb +4 -0
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +8 -13
- metadata +4 -2
data/lib/archspec/model.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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.
|
|
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 =
|
|
278
|
+
resolved = resolve_edge_constant(edge)
|
|
219
279
|
constants_named(resolved).each_with_object(Set.new) do |constant, names|
|
|
220
|
-
names.merge(
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
resolved_name = resolve_constant_reference(
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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: "#{
|
|
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 =
|
|
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: "#{
|
|
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.
|
|
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: "#{
|
|
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.
|
|
159
|
+
next unless graph.source_components_for(edge).include?(source)
|
|
168
160
|
|
|
169
|
-
referenced = graph.
|
|
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: "#{
|
|
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 +
|
|
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#
|
|
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
|
|
39
|
+
next if graph.source_components_for(edge).include?(source)
|
|
40
40
|
|
|
41
|
-
resolved = graph.
|
|
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: "#{
|
|
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.
|
|
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: "#{
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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: "#{
|
|
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
|
-
|
|
20
|
-
|
|
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
|
data/lib/archspec/version.rb
CHANGED