archspec 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +30 -11
- data/lib/archspec/analyzer.rb +224 -59
- data/lib/archspec/architectures.rb +134 -28
- data/lib/archspec/cli.rb +42 -88
- data/lib/archspec/component_spec.rb +3 -0
- data/lib/archspec/definition.rb +12 -4
- data/lib/archspec/diagnostic.rb +7 -1
- data/lib/archspec/dsl.rb +220 -16
- data/lib/archspec/evaluator.rb +4 -3
- data/lib/archspec/formatters/explanation.rb +93 -0
- data/lib/archspec/model.rb +61 -13
- data/lib/archspec/rules/component_rules.rb +2 -0
- data/lib/archspec/rules/concern_rules.rb +67 -0
- data/lib/archspec/rules/cycle_rule.rb +2 -0
- data/lib/archspec/rules/dependency_rules.rb +65 -0
- data/lib/archspec/rules/naming_rules.rb +209 -0
- data/lib/archspec/rules/privacy_rule.rb +81 -0
- data/lib/archspec/rules/protocol_rules.rb +62 -13
- data/lib/archspec/{baseline.rb → todo.rb} +5 -1
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +40 -3
- metadata +21 -5
- data/lib/archspec/presets.rb +0 -11
- data/lib/archspec/rules/zeitwerk_rule.rb +0 -27
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
|
+
# The checks ArchSpec runs. Each rule responds to +id+ (its stable rule id,
|
|
5
|
+
# used in suppressions and the todo file) and <tt>evaluate(graph)</tt>, which
|
|
6
|
+
# returns ArchSpec::Diagnostic objects. You normally add rules through the
|
|
7
|
+
# ArchSpec::DSL rather than instantiating these directly, but you can pass a
|
|
8
|
+
# custom object of the same shape to ArchSpec::DSL::Context#rule.
|
|
4
9
|
module Rules
|
|
10
|
+
# Base for the allow and forbid dependency rules. Merges repeated
|
|
11
|
+
# declarations for the same source component.
|
|
5
12
|
class DependencyRule
|
|
6
13
|
attr_reader :source, :targets
|
|
7
14
|
|
|
@@ -34,6 +41,8 @@ module ArchSpec
|
|
|
34
41
|
end
|
|
35
42
|
end
|
|
36
43
|
|
|
44
|
+
# Backs ArchSpec::DSL::ComponentProxy#can_only_use. Flags references from the
|
|
45
|
+
# source to any component outside its allowlist.
|
|
37
46
|
class AllowDependenciesRule < DependencyRule
|
|
38
47
|
def id
|
|
39
48
|
'dependencies.allow'
|
|
@@ -55,6 +64,8 @@ module ArchSpec
|
|
|
55
64
|
end
|
|
56
65
|
end
|
|
57
66
|
|
|
67
|
+
# Backs ArchSpec::DSL::ComponentProxy#cannot_use. Flags references from the
|
|
68
|
+
# source to any of the named components.
|
|
58
69
|
class ForbidDependenciesRule < DependencyRule
|
|
59
70
|
def id
|
|
60
71
|
'dependencies.forbid'
|
|
@@ -76,6 +87,60 @@ module ArchSpec
|
|
|
76
87
|
end
|
|
77
88
|
end
|
|
78
89
|
|
|
90
|
+
# Backs ArchSpec::DSL::ComponentProxy#can_only_be_used_by. The inverse of an
|
|
91
|
+
# allowlist: flags references to the component from any component that is not
|
|
92
|
+
# an approved consumer. Use it to protect a shared kernel.
|
|
93
|
+
class AllowedConsumersRule
|
|
94
|
+
attr_reader :source, :consumers
|
|
95
|
+
|
|
96
|
+
def initialize(source, consumers)
|
|
97
|
+
@source = source.to_sym
|
|
98
|
+
@consumers = Array(consumers).flatten.map(&:to_sym).to_set
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def merge_key
|
|
102
|
+
[self.class, source]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def merge!(other)
|
|
106
|
+
consumers.merge(other.consumers)
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def id
|
|
111
|
+
'dependencies.consumers'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def evaluate(graph)
|
|
115
|
+
graph.dependency_edges.flat_map do |edge|
|
|
116
|
+
next [] unless graph.target_components_for(edge).include?(source)
|
|
117
|
+
|
|
118
|
+
offenders = graph.component_names_for_path(edge.from_path).reject do |component|
|
|
119
|
+
component == source || consumers.include?(component)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
offenders.map do |offender|
|
|
123
|
+
Diagnostic.new(
|
|
124
|
+
rule: id,
|
|
125
|
+
message: message_for(offender),
|
|
126
|
+
location: edge.location,
|
|
127
|
+
evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge.to}"
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def message_for(offender)
|
|
136
|
+
return "#{source} may not be used by #{offender}" if consumers.empty?
|
|
137
|
+
|
|
138
|
+
"#{source} may only be used by #{consumers.to_a.sort.join(', ')}, not #{offender}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Backs ArchSpec::DSL::ComponentProxy#cannot_reference_constants. Flags
|
|
143
|
+
# references to the named constants or anything nested under them.
|
|
79
144
|
class CannotReferenceConstantsRule
|
|
80
145
|
attr_reader :source, :constants
|
|
81
146
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module ArchSpec
|
|
6
|
+
module Rules
|
|
7
|
+
# Backs the +methods.matching(...)+ DSL. One rule pairs a selector (which
|
|
8
|
+
# methods it judges) with a constraint (what must hold of them). It judges a
|
|
9
|
+
# component's defined, public methods in the requested scope, or every such
|
|
10
|
+
# method in the project when +source+ is nil. Every finding is name-based and
|
|
11
|
+
# exact, so confidence is always +:high+.
|
|
12
|
+
class NamingRule
|
|
13
|
+
attr_reader :source, :selector, :scope, :except
|
|
14
|
+
|
|
15
|
+
def initialize(source:, selector:, constraint:, scope: :instance, except: [])
|
|
16
|
+
@source = source&.to_sym
|
|
17
|
+
@selector = selector
|
|
18
|
+
@constraint = constraint
|
|
19
|
+
@scope = scope
|
|
20
|
+
@except = Array(except).flatten.map(&:to_sym).to_set
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def id
|
|
24
|
+
@constraint.id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def evaluate(graph)
|
|
28
|
+
selected = candidate_methods(graph).filter_map do |definition|
|
|
29
|
+
next if except.include?(definition.name)
|
|
30
|
+
|
|
31
|
+
match = selector.match(definition)
|
|
32
|
+
[definition, match] if match
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@constraint.diagnostics(selected, self, graph)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def candidate_methods(graph)
|
|
41
|
+
definitions = source ? graph.method_definitions_for_component(source) : graph.method_definitions
|
|
42
|
+
definitions.select { |definition| definition.scope == scope && definition.visibility == :public }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The selectors and constraints the naming DSL composes, plus the builder the
|
|
47
|
+
# DSL returns.
|
|
48
|
+
module Naming
|
|
49
|
+
# Selects methods whose name matches a regex. A named capture in the regex
|
|
50
|
+
# (such as <tt>(?<base>.+)</tt>) is exposed to the +requires+ constraint.
|
|
51
|
+
class NameSelector
|
|
52
|
+
attr_reader :regex
|
|
53
|
+
|
|
54
|
+
def initialize(regex)
|
|
55
|
+
@regex = regex
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def match(definition)
|
|
59
|
+
regex.match(definition.name.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def describe
|
|
63
|
+
"matches #{regex.inspect}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Forbids any selected method from existing. Rule id +naming.forbidden+.
|
|
68
|
+
class Forbidden
|
|
69
|
+
def initialize(because: nil)
|
|
70
|
+
@because = because
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def id
|
|
74
|
+
'naming.forbidden'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def diagnostics(selected, rule, _graph)
|
|
78
|
+
selected.map do |definition, _match|
|
|
79
|
+
Diagnostic.new(
|
|
80
|
+
rule: id,
|
|
81
|
+
message: message_for(definition),
|
|
82
|
+
location: definition.location,
|
|
83
|
+
evidence: "#{definition.owner} defines #{definition.scope} method #{definition.name} (#{rule.selector.describe})"
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def message_for(definition)
|
|
91
|
+
base = "#{definition.owner} must not define ##{definition.name}"
|
|
92
|
+
@because ? "#{base}: #{@because}" : base
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Requires each selected method to have a sibling named by a template, in
|
|
97
|
+
# the same component or another (+on:+), at a given scope. The template
|
|
98
|
+
# interpolates the selector's named captures, as in
|
|
99
|
+
# <tt>requires("without_%{base}")</tt>. Rule id +naming.requires+.
|
|
100
|
+
class Requires
|
|
101
|
+
def initialize(template, on: nil, scope: :instance, because: nil)
|
|
102
|
+
@template = template
|
|
103
|
+
@on = on
|
|
104
|
+
@target_scope = scope
|
|
105
|
+
@because = because
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def id
|
|
109
|
+
'naming.requires'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def diagnostics(selected, rule, graph)
|
|
113
|
+
target = @on || rule.source
|
|
114
|
+
existing = existing_names(graph, target)
|
|
115
|
+
|
|
116
|
+
selected.filter_map do |definition, match|
|
|
117
|
+
sibling = expand(match)
|
|
118
|
+
next if sibling.nil? || existing.include?(sibling.to_sym)
|
|
119
|
+
|
|
120
|
+
Diagnostic.new(
|
|
121
|
+
rule: id,
|
|
122
|
+
message: message_for(definition, sibling, target, rule),
|
|
123
|
+
location: definition.location,
|
|
124
|
+
evidence: "#{definition.owner} defines ##{definition.name}, expected ##{sibling}"
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def existing_names(graph, target)
|
|
132
|
+
definitions = target ? graph.method_definitions_for_component(target) : graph.method_definitions
|
|
133
|
+
definitions.select { |definition| definition.scope == @target_scope }.map(&:name).to_set
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def expand(match)
|
|
137
|
+
@template % captures(match)
|
|
138
|
+
rescue KeyError, ArgumentError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def captures(match)
|
|
143
|
+
match.is_a?(MatchData) ? match.named_captures.transform_keys(&:to_sym) : {}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def message_for(definition, sibling, target, rule)
|
|
147
|
+
clause =
|
|
148
|
+
if target && target != rule.source
|
|
149
|
+
"#{target} to define ##{sibling}"
|
|
150
|
+
else
|
|
151
|
+
"a matching ##{sibling}"
|
|
152
|
+
end
|
|
153
|
+
base = "#{definition.owner}##{definition.name} requires #{clause}"
|
|
154
|
+
@because ? "#{base}: #{@because}" : base
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Returned by ArchSpec::DSL::ComponentProxy#methods. Starts a selector.
|
|
159
|
+
class Builder
|
|
160
|
+
def initialize(component, scope: :instance)
|
|
161
|
+
@component = component
|
|
162
|
+
@scope = scope
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def matching(regex)
|
|
166
|
+
Selected.new(@component, NameSelector.new(regex), @scope)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A chosen selector, waiting for a constraint. Each constraint method builds
|
|
171
|
+
# a NamingRule, attaches it, and returns the component proxy so rules chain.
|
|
172
|
+
class Selected
|
|
173
|
+
def initialize(component, selector, scope)
|
|
174
|
+
@component = component
|
|
175
|
+
@selector = selector
|
|
176
|
+
@scope = scope
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def forbidden(except: [], because: nil)
|
|
180
|
+
add(Forbidden.new(because: because), except)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def requires(template, on: nil, scope: :instance, except: [], because: nil)
|
|
184
|
+
add(Requires.new(template, on: component_name(on), scope: scope, because: because), except)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def add(constraint, except)
|
|
190
|
+
rule = NamingRule.new(
|
|
191
|
+
source: @component.name,
|
|
192
|
+
selector: @selector,
|
|
193
|
+
constraint: constraint,
|
|
194
|
+
scope: @scope,
|
|
195
|
+
except: except
|
|
196
|
+
)
|
|
197
|
+
@component.definition.add_rule(rule)
|
|
198
|
+
@component
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def component_name(target)
|
|
202
|
+
return if target.nil?
|
|
203
|
+
|
|
204
|
+
target.respond_to?(:name) ? target.name : target.to_sym
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Rules
|
|
5
|
+
# Backs ArchSpec::DSL::ComponentProxy#public_api. Flags references from
|
|
6
|
+
# outside the component to constants that are not part of its public API.
|
|
7
|
+
class PublicApiRule
|
|
8
|
+
attr_reader :source, :file_patterns, :constants, :namespaces
|
|
9
|
+
|
|
10
|
+
def initialize(source, files: [], constants: nil, namespaces: nil)
|
|
11
|
+
@source = source.to_sym
|
|
12
|
+
@file_patterns = Array(files).flatten.compact.map(&:to_s)
|
|
13
|
+
@constants = Array(constants).compact.map { |value| normalize_constant(value) }
|
|
14
|
+
@namespaces = Array(namespaces).compact.map { |value| normalize_constant(value) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def merge_key
|
|
18
|
+
[self.class, source]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def merge!(other)
|
|
22
|
+
@file_patterns |= other.file_patterns
|
|
23
|
+
@constants |= other.constants
|
|
24
|
+
@namespaces |= other.namespaces
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def id
|
|
29
|
+
'dependencies.privacy'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def evaluate(graph)
|
|
33
|
+
component = graph.components[source]
|
|
34
|
+
return [] unless component
|
|
35
|
+
|
|
36
|
+
public_names = public_constant_names(graph)
|
|
37
|
+
|
|
38
|
+
graph.dependency_edges.filter_map do |edge|
|
|
39
|
+
next if component.files.include?(edge.from_path)
|
|
40
|
+
|
|
41
|
+
resolved = graph.resolve_constant_reference(edge.to, edge.from_constant)
|
|
42
|
+
next unless graph.component_names_for_constant(resolved).include?(source)
|
|
43
|
+
next if public?(resolved, public_names)
|
|
44
|
+
|
|
45
|
+
Diagnostic.new(
|
|
46
|
+
rule: id,
|
|
47
|
+
message: "#{resolved} is private to #{source}",
|
|
48
|
+
location: edge.location,
|
|
49
|
+
evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{resolved}"
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def public_constant_names(graph)
|
|
57
|
+
paths = file_patterns.flat_map do |pattern|
|
|
58
|
+
Dir.glob(File.absolute_path(pattern, graph.root))
|
|
59
|
+
end.map { |path| File.expand_path(path) }.to_set
|
|
60
|
+
|
|
61
|
+
names = graph.constants.select { |constant| paths.include?(constant.path) }.map(&:name)
|
|
62
|
+
(names + constants).to_set
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Names from public files and constants: match exactly. Reopened
|
|
66
|
+
# namespace modules land in public files too, so prefix matching there
|
|
67
|
+
# would make the whole namespace public. Use namespace: for prefixes.
|
|
68
|
+
def public?(name, public_names)
|
|
69
|
+
return true if public_names.include?(name)
|
|
70
|
+
|
|
71
|
+
namespaces.any? do |namespace|
|
|
72
|
+
name == namespace || name.start_with?("#{namespace}::")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_constant(value)
|
|
77
|
+
value.to_s.sub(/\A::/, '')
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
4
|
module Rules
|
|
5
|
+
# Backs ArchSpec::DSL::ComponentProxy#cannot_call. Flags calls to the named
|
|
6
|
+
# methods, optionally only bare implicit-+self+ calls.
|
|
5
7
|
class CannotCallRule
|
|
6
|
-
attr_reader :source, :method_names
|
|
8
|
+
attr_reader :source, :method_names, :receiver
|
|
9
|
+
|
|
10
|
+
def initialize(source, methods, receiver: :any)
|
|
11
|
+
unless %i[any none].include?(receiver)
|
|
12
|
+
raise Error, "cannot_call receiver: must be :any or :none, got #{receiver.inspect}"
|
|
13
|
+
end
|
|
7
14
|
|
|
8
|
-
def initialize(source, methods)
|
|
9
15
|
@source = source.to_sym
|
|
10
16
|
@method_names = Array(methods).flatten.map(&:to_sym)
|
|
17
|
+
@receiver = receiver
|
|
11
18
|
end
|
|
12
19
|
|
|
13
20
|
def merge_key
|
|
14
|
-
[self.class, source]
|
|
21
|
+
[self.class, source, receiver]
|
|
15
22
|
end
|
|
16
23
|
|
|
17
24
|
def merge!(other)
|
|
@@ -27,7 +34,9 @@ module ArchSpec
|
|
|
27
34
|
graph.edges.filter_map do |edge|
|
|
28
35
|
next unless edge.type == :calls_named_method
|
|
29
36
|
next unless method_names.include?(edge.to.to_sym)
|
|
37
|
+
next if receiver == :none && edge.receiver != :none
|
|
30
38
|
next unless graph.component_names_for_path(edge.from_path).include?(source)
|
|
39
|
+
next if own_method_call?(graph, edge)
|
|
31
40
|
|
|
32
41
|
Diagnostic.new(
|
|
33
42
|
rule: id,
|
|
@@ -37,8 +46,22 @@ module ArchSpec
|
|
|
37
46
|
)
|
|
38
47
|
end
|
|
39
48
|
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# A receiverless call to a method the class itself defines (directly,
|
|
53
|
+
# inherited, or via attr_*/attribute/delegate) is a call to its own API.
|
|
54
|
+
def own_method_call?(graph, edge)
|
|
55
|
+
return false unless edge.receiver == :none && edge.from_constant
|
|
56
|
+
|
|
57
|
+
methods, = graph.effective_instance_methods(edge.from_constant)
|
|
58
|
+
methods.include?(edge.to.to_sym)
|
|
59
|
+
end
|
|
40
60
|
end
|
|
41
61
|
|
|
62
|
+
# Backs ArchSpec::DSL::ComponentProxy#must_implement. Flags classes in the
|
|
63
|
+
# component that do not implement the method, counting inherited and
|
|
64
|
+
# mixed-in methods.
|
|
42
65
|
class MustImplementRule
|
|
43
66
|
attr_reader :source, :method_name
|
|
44
67
|
|
|
@@ -57,13 +80,15 @@ module ArchSpec
|
|
|
57
80
|
|
|
58
81
|
def evaluate(graph)
|
|
59
82
|
constants_for(graph).filter_map do |constant|
|
|
60
|
-
|
|
83
|
+
methods, unresolved = graph.effective_instance_methods(constant.name)
|
|
84
|
+
next if methods.include?(method_name)
|
|
61
85
|
|
|
62
86
|
Diagnostic.new(
|
|
63
87
|
rule: id,
|
|
64
88
|
message: "#{constant.name} must implement ##{method_name}",
|
|
65
89
|
location: constant.location,
|
|
66
|
-
evidence:
|
|
90
|
+
evidence: ProtocolEvidence.for(constant, methods, unresolved),
|
|
91
|
+
confidence: unresolved.empty? ? :high : :medium
|
|
67
92
|
)
|
|
68
93
|
end
|
|
69
94
|
end
|
|
@@ -71,12 +96,12 @@ module ArchSpec
|
|
|
71
96
|
private
|
|
72
97
|
|
|
73
98
|
def constants_for(graph)
|
|
74
|
-
|
|
75
|
-
rescue KeyError
|
|
76
|
-
[]
|
|
99
|
+
ProtocolEvidence.constants_for(graph, source)
|
|
77
100
|
end
|
|
78
101
|
end
|
|
79
102
|
|
|
103
|
+
# Backs ArchSpec::DSL::ComponentProxy#must_implement_one_of. Flags classes
|
|
104
|
+
# that implement none of the named methods.
|
|
80
105
|
class MustImplementOneOfRule
|
|
81
106
|
attr_reader :source, :method_names
|
|
82
107
|
|
|
@@ -100,13 +125,15 @@ module ArchSpec
|
|
|
100
125
|
|
|
101
126
|
def evaluate(graph)
|
|
102
127
|
constants_for(graph).filter_map do |constant|
|
|
103
|
-
|
|
128
|
+
methods, unresolved = graph.effective_instance_methods(constant.name)
|
|
129
|
+
next if method_names.any? { |method_name| methods.include?(method_name) }
|
|
104
130
|
|
|
105
131
|
Diagnostic.new(
|
|
106
132
|
rule: id,
|
|
107
133
|
message: "#{constant.name} must implement one of #{method_names.map { |name| "##{name}" }.join(', ')}",
|
|
108
134
|
location: constant.location,
|
|
109
|
-
evidence:
|
|
135
|
+
evidence: ProtocolEvidence.for(constant, methods, unresolved),
|
|
136
|
+
confidence: unresolved.empty? ? :high : :medium
|
|
110
137
|
)
|
|
111
138
|
end
|
|
112
139
|
end
|
|
@@ -114,12 +141,32 @@ module ArchSpec
|
|
|
114
141
|
private
|
|
115
142
|
|
|
116
143
|
def constants_for(graph)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
144
|
+
ProtocolEvidence.constants_for(graph, source)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Builds the shared "methods: ..." evidence string for the protocol rules
|
|
149
|
+
# and resolves the classes in a component. Internal helper.
|
|
150
|
+
module ProtocolEvidence
|
|
151
|
+
module_function
|
|
152
|
+
|
|
153
|
+
def constants_for(graph, source)
|
|
154
|
+
component = graph.components[source]
|
|
155
|
+
return [] unless component
|
|
156
|
+
|
|
157
|
+
component.constants.flat_map { |name| graph.constants_named(name) }.select(&:class?).uniq(&:name)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def for(constant, methods, unresolved)
|
|
161
|
+
evidence = "#{constant.name} methods: #{methods.empty? ? '(none)' : methods.to_a.sort.join(', ')}"
|
|
162
|
+
return evidence if unresolved.empty?
|
|
163
|
+
|
|
164
|
+
"#{evidence}; unresolved ancestors: #{unresolved.to_a.sort.join(', ')}"
|
|
120
165
|
end
|
|
121
166
|
end
|
|
122
167
|
|
|
168
|
+
# Backs ArchSpec::DSL::ComponentProxy#cannot_define. Flags method
|
|
169
|
+
# definitions in the component matching the named methods.
|
|
123
170
|
class CannotDefineMethodRule
|
|
124
171
|
attr_reader :source, :method_names
|
|
125
172
|
|
|
@@ -155,6 +202,8 @@ module ArchSpec
|
|
|
155
202
|
end
|
|
156
203
|
end
|
|
157
204
|
|
|
205
|
+
# Backs ArchSpec::DSL::ComponentProxy#cannot_instantiate_and_invoke. Flags
|
|
206
|
+
# the one-shot <tt>Thing.new(...).call</tt> pattern.
|
|
158
207
|
class CannotInstantiateAndInvokeRule
|
|
159
208
|
attr_reader :source
|
|
160
209
|
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
|
|
5
5
|
module ArchSpec
|
|
6
|
-
|
|
6
|
+
# A file of accepted existing violations. ArchSpec still checks the files
|
|
7
|
+
# these come from, but subtracts the recorded violations so you can adopt it
|
|
8
|
+
# in an existing app and burn the list down over time. Matched by
|
|
9
|
+
# ArchSpec::Diagnostic#fingerprint, so entries survive edits that shift lines.
|
|
10
|
+
class Todo
|
|
7
11
|
def self.empty(root: nil)
|
|
8
12
|
new(Set.new, root: root)
|
|
9
13
|
end
|
data/lib/archspec/version.rb
CHANGED
data/lib/archspec.rb
CHANGED
|
@@ -7,27 +7,64 @@ require_relative 'archspec/diagnostic'
|
|
|
7
7
|
require_relative 'archspec/component_spec'
|
|
8
8
|
require_relative 'archspec/model'
|
|
9
9
|
require_relative 'archspec/definition'
|
|
10
|
-
require_relative 'archspec/
|
|
10
|
+
require_relative 'archspec/todo'
|
|
11
11
|
require_relative 'archspec/dsl'
|
|
12
12
|
require_relative 'archspec/analyzer'
|
|
13
13
|
require_relative 'archspec/evaluator'
|
|
14
14
|
require_relative 'archspec/architectures'
|
|
15
|
-
require_relative 'archspec/presets'
|
|
16
15
|
require_relative 'archspec/rules/component_rules'
|
|
16
|
+
require_relative 'archspec/rules/concern_rules'
|
|
17
17
|
require_relative 'archspec/rules/dependency_rules'
|
|
18
|
+
require_relative 'archspec/rules/naming_rules'
|
|
19
|
+
require_relative 'archspec/rules/privacy_rule'
|
|
18
20
|
require_relative 'archspec/rules/protocol_rules'
|
|
19
21
|
require_relative 'archspec/rules/cycle_rule'
|
|
20
|
-
require_relative 'archspec/rules/zeitwerk_rule'
|
|
21
22
|
require_relative 'archspec/formatters/text'
|
|
22
23
|
require_relative 'archspec/formatters/json'
|
|
24
|
+
require_relative 'archspec/formatters/explanation'
|
|
23
25
|
require_relative 'archspec/cli'
|
|
24
26
|
|
|
27
|
+
# ArchSpec turns your application's architecture into executable checks.
|
|
28
|
+
#
|
|
29
|
+
# You describe components, dependencies, and boundaries in an +Archspec.rb+
|
|
30
|
+
# file written in the ArchSpec::DSL, then run <tt>archspec check</tt> to verify
|
|
31
|
+
# every change. ArchSpec reads Ruby source with Prism and never boots the app.
|
|
32
|
+
#
|
|
33
|
+
# The DSL is the public API. An +Archspec.rb+ file is evaluated directly:
|
|
34
|
+
#
|
|
35
|
+
# architecture :rails
|
|
36
|
+
#
|
|
37
|
+
# component :services, in: "app/services/**/*.rb"
|
|
38
|
+
# services.cannot_call :render, :redirect_to, receiver: :none
|
|
39
|
+
#
|
|
40
|
+
# See ArchSpec::DSL::Context for the top-level DSL and
|
|
41
|
+
# ArchSpec::DSL::ComponentProxy for per-component rules. See
|
|
42
|
+
# ArchSpec::Architectures for the bundled architecture presets.
|
|
43
|
+
#
|
|
44
|
+
# You can also build a definition in plain Ruby with ArchSpec.define.
|
|
25
45
|
module ArchSpec
|
|
46
|
+
# Raised for configuration and usage errors, such as an unknown architecture
|
|
47
|
+
# name or a malformed rule option.
|
|
26
48
|
class Error < StandardError; end
|
|
27
49
|
|
|
28
50
|
class << self
|
|
51
|
+
# The definition produced by the most recent ArchSpec.define call, or by
|
|
52
|
+
# evaluating an +Archspec.rb+ file. The CLI reads this after loading config.
|
|
29
53
|
attr_accessor :last_definition
|
|
30
54
|
|
|
55
|
+
# Builds an architecture definition from a block of DSL calls.
|
|
56
|
+
#
|
|
57
|
+
# ArchSpec.define do
|
|
58
|
+
# component :models, in: "app/models/**/*.rb"
|
|
59
|
+
# component :controllers, in: "app/controllers/**/*.rb"
|
|
60
|
+
# models.cannot_use :controllers
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# An +Archspec.rb+ file does not need this wrapper. Its top level is already
|
|
64
|
+
# the DSL, so bare +component+ and +architecture+ calls work directly. Use
|
|
65
|
+
# +define+ when constructing a definition from Ruby, such as in a test.
|
|
66
|
+
#
|
|
67
|
+
# Returns the ArchSpec::Definition, which is also stored as last_definition.
|
|
31
68
|
def define(name = nil, &block)
|
|
32
69
|
definition = Definition.new(name)
|
|
33
70
|
definition.extend(DSL::Context)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: archspec
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carmine Paolino
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: prism
|
|
@@ -58,6 +58,20 @@ dependencies:
|
|
|
58
58
|
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
60
|
version: '13.0'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: rdoc
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '6.6'
|
|
68
|
+
type: :development
|
|
69
|
+
prerelease: false
|
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '6.6'
|
|
61
75
|
description: A static architecture linter for Ruby and Rails. Declare your components,
|
|
62
76
|
dependencies, and boundaries in one file, then check every change in CI. It reads
|
|
63
77
|
source with Prism and never boots the app.
|
|
@@ -74,23 +88,25 @@ files:
|
|
|
74
88
|
- lib/archspec.rb
|
|
75
89
|
- lib/archspec/analyzer.rb
|
|
76
90
|
- lib/archspec/architectures.rb
|
|
77
|
-
- lib/archspec/baseline.rb
|
|
78
91
|
- lib/archspec/cli.rb
|
|
79
92
|
- lib/archspec/component_spec.rb
|
|
80
93
|
- lib/archspec/definition.rb
|
|
81
94
|
- lib/archspec/diagnostic.rb
|
|
82
95
|
- lib/archspec/dsl.rb
|
|
83
96
|
- lib/archspec/evaluator.rb
|
|
97
|
+
- lib/archspec/formatters/explanation.rb
|
|
84
98
|
- lib/archspec/formatters/json.rb
|
|
85
99
|
- lib/archspec/formatters/text.rb
|
|
86
100
|
- lib/archspec/model.rb
|
|
87
|
-
- lib/archspec/presets.rb
|
|
88
101
|
- lib/archspec/rules/component_rules.rb
|
|
102
|
+
- lib/archspec/rules/concern_rules.rb
|
|
89
103
|
- lib/archspec/rules/cycle_rule.rb
|
|
90
104
|
- lib/archspec/rules/dependency_rules.rb
|
|
105
|
+
- lib/archspec/rules/naming_rules.rb
|
|
106
|
+
- lib/archspec/rules/privacy_rule.rb
|
|
91
107
|
- lib/archspec/rules/protocol_rules.rb
|
|
92
|
-
- lib/archspec/rules/zeitwerk_rule.rb
|
|
93
108
|
- lib/archspec/source_location.rb
|
|
109
|
+
- lib/archspec/todo.rb
|
|
94
110
|
- lib/archspec/value_object.rb
|
|
95
111
|
- lib/archspec/version.rb
|
|
96
112
|
homepage: https://archspecrb.dev
|