archspec 0.4.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 +7 -3
- data/lib/archspec/analyzer.rb +149 -60
- data/lib/archspec/architectures.rb +61 -32
- data/lib/archspec/cli.rb +1 -73
- data/lib/archspec/definition.rb +1 -6
- data/lib/archspec/dsl.rb +34 -43
- data/lib/archspec/formatters/explanation.rb +93 -0
- data/lib/archspec/model.rb +23 -10
- data/lib/archspec/rules/cycle_rule.rb +1 -1
- data/lib/archspec/rules/dependency_rules.rb +1 -1
- data/lib/archspec/rules/naming_rules.rb +209 -0
- data/lib/archspec/rules/protocol_rules.rb +1 -1
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +2 -2
- metadata +4 -4
- data/lib/archspec/presets.rb +0 -14
- data/lib/archspec/rules/zeitwerk_rule.rb +0 -51
data/lib/archspec/dsl.rb
CHANGED
|
@@ -56,16 +56,6 @@ module ArchSpec
|
|
|
56
56
|
self.todo_path = path.to_s
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# Declares acronym inflections for Zeitwerk name checks, mirroring your
|
|
60
|
-
# +config/initializers/inflections.rb+.
|
|
61
|
-
#
|
|
62
|
-
# inflect "api" => "API", "graphql" => "GraphQL"
|
|
63
|
-
#
|
|
64
|
-
# +app/models/api_client.rb+ then expects +APIClient+.
|
|
65
|
-
def inflect(map)
|
|
66
|
-
add_inflections(map)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
59
|
# Yields each subdirectory matching a glob, so you can declare one
|
|
70
60
|
# component per engine or pack without hardcoding their names. Paths
|
|
71
61
|
# resolve against the +Archspec.rb+ directory, not the working directory,
|
|
@@ -97,9 +87,6 @@ module ArchSpec
|
|
|
97
87
|
#
|
|
98
88
|
# Returns an ArchSpec::DSL::ComponentProxy for attaching rules. The
|
|
99
89
|
# component is also available by name later in the file.
|
|
100
|
-
#
|
|
101
|
-
# +layer+ and +role+ are aliases. Use whichever word fits the
|
|
102
|
-
# architecture you are describing.
|
|
103
90
|
def component(name, in: nil, namespace: nil, constants: nil)
|
|
104
91
|
add_component(
|
|
105
92
|
ComponentSpec.new(name, files: binding.local_variable_get(:in), namespace: namespace, constants: constants)
|
|
@@ -107,9 +94,6 @@ module ArchSpec
|
|
|
107
94
|
ComponentProxy.new(self, name)
|
|
108
95
|
end
|
|
109
96
|
|
|
110
|
-
alias layer component
|
|
111
|
-
alias role component
|
|
112
|
-
|
|
113
97
|
# Applies a bundled architecture preset, defining its components and
|
|
114
98
|
# rules together.
|
|
115
99
|
#
|
|
@@ -117,34 +101,28 @@ module ArchSpec
|
|
|
117
101
|
# architecture :hexagonal
|
|
118
102
|
# architecture :modular_monolith, components: { ... }, allow: { ... }
|
|
119
103
|
#
|
|
104
|
+
# +preset+ is an alias. Use whichever word fits: +architecture+ reads well
|
|
105
|
+
# for structural bundles like +:rails+, +preset+ for convention packs like
|
|
106
|
+
# +:ruby_conventions+.
|
|
107
|
+
#
|
|
120
108
|
# See ArchSpec::Architectures for every preset and its options.
|
|
121
109
|
def architecture(name, **options)
|
|
122
110
|
Architectures.apply(name, self, **options)
|
|
123
111
|
end
|
|
124
112
|
|
|
113
|
+
alias preset architecture
|
|
114
|
+
|
|
125
115
|
# Forbids dependency cycles between components. Pass +among:+ to limit the
|
|
126
116
|
# check to a subset; omit it to check every declared component.
|
|
127
117
|
#
|
|
128
|
-
# no_cycles
|
|
129
|
-
# no_cycles
|
|
118
|
+
# no_cycles
|
|
119
|
+
# no_cycles among: %i[billing catalog shared]
|
|
130
120
|
#
|
|
131
121
|
# Rule id: +dependencies.no_cycles+.
|
|
132
|
-
def no_cycles
|
|
122
|
+
def no_cycles(among: nil)
|
|
133
123
|
add_rule(Rules::NoCyclesRule.new(among: among))
|
|
134
124
|
end
|
|
135
125
|
|
|
136
|
-
# Checks that files define the constant their path implies under Zeitwerk.
|
|
137
|
-
# Pass globs to restrict the check to the autoloaded tree, since Rails
|
|
138
|
-
# does not autoload +lib+ by default.
|
|
139
|
-
#
|
|
140
|
-
# verify_zeitwerk_names!
|
|
141
|
-
# verify_zeitwerk_names! "app/**/*.rb"
|
|
142
|
-
#
|
|
143
|
-
# Rule id: +zeitwerk.naming+.
|
|
144
|
-
def verify_zeitwerk_names!(*only)
|
|
145
|
-
add_rule(Rules::ZeitwerkNamingRule.new(only: only))
|
|
146
|
-
end
|
|
147
|
-
|
|
148
126
|
# Adds a custom rule object. A rule responds to +id+ and
|
|
149
127
|
# <tt>evaluate(graph)</tt>, returning ArchSpec::Diagnostic objects. Use
|
|
150
128
|
# this to extend ArchSpec with project-specific checks.
|
|
@@ -176,23 +154,20 @@ module ArchSpec
|
|
|
176
154
|
@name = name.to_sym
|
|
177
155
|
end
|
|
178
156
|
|
|
179
|
-
# Allowlists the components this one may depend on
|
|
180
|
-
# other declared
|
|
157
|
+
# Allowlists the components this one may depend on: only the listed
|
|
158
|
+
# components are permitted, and a reference to any other declared
|
|
159
|
+
# component fails. The mirror image of #can_only_be_used_by.
|
|
181
160
|
#
|
|
182
|
-
# controllers.
|
|
161
|
+
# controllers.can_only_use :models, :services
|
|
183
162
|
#
|
|
184
|
-
# +only_depend_on+ and +must_only_depend_on+ are aliases.
|
|
185
163
|
# Rule id: +dependencies.allow+.
|
|
186
|
-
def
|
|
164
|
+
def can_only_use(*targets)
|
|
187
165
|
add_rule(Rules::AllowDependenciesRule.new(name, targets))
|
|
188
166
|
self
|
|
189
167
|
end
|
|
190
168
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
# Forbids depending on the named components. Narrower than #can_use: only
|
|
195
|
-
# the listed components fail, other dependencies are left alone.
|
|
169
|
+
# Forbids depending on the named components. Narrower than #can_only_use:
|
|
170
|
+
# only the listed components fail, other dependencies are left alone.
|
|
196
171
|
#
|
|
197
172
|
# models.cannot_use :controllers, :helpers
|
|
198
173
|
#
|
|
@@ -203,7 +178,7 @@ module ArchSpec
|
|
|
203
178
|
end
|
|
204
179
|
|
|
205
180
|
# Allowlists the components that may reference this one, the inverse of
|
|
206
|
-
# #
|
|
181
|
+
# #can_only_use. A reference from any other component fails. Use it to protect
|
|
207
182
|
# a shared kernel or a component with a deliberately narrow audience.
|
|
208
183
|
#
|
|
209
184
|
# shared_kernel.can_only_be_used_by :billing, :catalog
|
|
@@ -223,7 +198,8 @@ module ArchSpec
|
|
|
223
198
|
# services.cannot_call :render, :params, receiver: :none
|
|
224
199
|
#
|
|
225
200
|
# A bare call to a method the component defines, inherits, or generates
|
|
226
|
-
# with +attr_
|
|
201
|
+
# with +attr_*+, Rails +attribute+, or +delegate+ is treated as its own API
|
|
202
|
+
# and not flagged.
|
|
227
203
|
# Rule id: +methods.forbid+.
|
|
228
204
|
def cannot_call(*methods, receiver: :any)
|
|
229
205
|
add_rule(Rules::CannotCallRule.new(name, methods, receiver: receiver))
|
|
@@ -329,6 +305,21 @@ module ArchSpec
|
|
|
329
305
|
self
|
|
330
306
|
end
|
|
331
307
|
|
|
308
|
+
# Starts a naming-convention rule over the component's defined, public
|
|
309
|
+
# methods. Select the methods with +matching+, then assert something about
|
|
310
|
+
# them. Every check is name-based and exact.
|
|
311
|
+
#
|
|
312
|
+
# models.method_names.matching(/\A(get|set)_/).forbidden
|
|
313
|
+
# chat.method_names.matching(/\Awith_(?<base>.+)/).requires("without_%{base}")
|
|
314
|
+
# chat.method_names.matching(/\Awith_(?<b>.+)/).requires("%{b}", on: agent, scope: :class)
|
|
315
|
+
#
|
|
316
|
+
# Pass <tt>scope: :class</tt> to select class methods instead of instance
|
|
317
|
+
# methods. See ArchSpec::Rules::Naming::Selected for the constraints
|
|
318
|
+
# (+forbidden+, +requires+). Rule ids: +naming.forbidden+, +naming.requires+.
|
|
319
|
+
def method_names(scope: :instance)
|
|
320
|
+
Rules::Naming::Builder.new(self, scope: scope)
|
|
321
|
+
end
|
|
322
|
+
|
|
332
323
|
private
|
|
333
324
|
|
|
334
325
|
def add_rule(rule)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Formatters
|
|
5
|
+
# Renders <tt>archspec explain</tt>: why a file or constant belongs to its
|
|
6
|
+
# components, and the facts ArchSpec found for it. Raises ArchSpec::Error
|
|
7
|
+
# when the subject matches no file and no constant.
|
|
8
|
+
module Explanation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def print(output = $stdout, graph:, subject:)
|
|
12
|
+
path = File.expand_path(subject, graph.root)
|
|
13
|
+
|
|
14
|
+
if graph.files.key?(path)
|
|
15
|
+
explain_file(output, graph, path)
|
|
16
|
+
else
|
|
17
|
+
explain_constant(output, graph, subject)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def explain_file(output, graph, path)
|
|
22
|
+
file = graph.files.fetch(path)
|
|
23
|
+
output.puts file.relative_path
|
|
24
|
+
output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(', ')}"
|
|
25
|
+
print_parse_errors(output, file)
|
|
26
|
+
print_component_reasons(output, graph.component_assignment_reasons_for_path(path))
|
|
27
|
+
print_suppressions(output, file)
|
|
28
|
+
output.puts ' outgoing facts:'
|
|
29
|
+
|
|
30
|
+
graph.edges.select { |edge| edge.from_path == path }.each do |edge|
|
|
31
|
+
output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def explain_constant(output, graph, subject)
|
|
36
|
+
constants = graph.constants_named(subject)
|
|
37
|
+
raise Error, "No file or constant found for #{subject.inspect}" if constants.empty?
|
|
38
|
+
|
|
39
|
+
constants.each do |constant|
|
|
40
|
+
output.puts constant.name
|
|
41
|
+
output.puts " kind: #{constant.kind}"
|
|
42
|
+
output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
|
|
43
|
+
print_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
|
|
44
|
+
output.puts " superclass: #{constant.superclass || '(none)'}"
|
|
45
|
+
output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(', ')}"
|
|
46
|
+
output.puts " class methods: #{constant.class_methods.to_a.sort.join(', ')}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def print_component_reasons(output, assignments)
|
|
51
|
+
if assignments.empty?
|
|
52
|
+
output.puts ' components: (none)'
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
output.puts ' components:'
|
|
57
|
+
assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
|
|
58
|
+
output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def print_suppressions(output, file)
|
|
63
|
+
return if file.suppressions.empty?
|
|
64
|
+
|
|
65
|
+
output.puts ' suppressions:'
|
|
66
|
+
file.suppressions.each do |suppression|
|
|
67
|
+
rule = suppression.rule || '*'
|
|
68
|
+
reason = suppression.reason ? " -- #{suppression.reason}" : ''
|
|
69
|
+
output.puts " #{rule} on line #{line_range(suppression)}#{reason}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def print_parse_errors(output, file)
|
|
74
|
+
return if file.parse_errors.empty?
|
|
75
|
+
|
|
76
|
+
output.puts ' parse errors:'
|
|
77
|
+
file.parse_errors.each do |parse_error|
|
|
78
|
+
output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def line_range(suppression)
|
|
83
|
+
if suppression.end_line == Float::INFINITY
|
|
84
|
+
"#{suppression.start_line}-EOF"
|
|
85
|
+
elsif suppression.start_line == suppression.end_line
|
|
86
|
+
suppression.start_line
|
|
87
|
+
else
|
|
88
|
+
"#{suppression.start_line}-#{suppression.end_line}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/archspec/model.rb
CHANGED
|
@@ -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,12 +18,11 @@ module ArchSpec
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
class SourceFile
|
|
21
|
-
attr_reader :path, :relative_path, :
|
|
21
|
+
attr_reader :path, :relative_path, :parse_errors, :suppressions
|
|
22
22
|
|
|
23
|
-
def initialize(root:, path:,
|
|
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
|
|
@@ -56,19 +55,28 @@ module ArchSpec
|
|
|
56
55
|
kind == :module
|
|
57
56
|
end
|
|
58
57
|
|
|
59
|
-
def add_instance_method(name, location:)
|
|
58
|
+
def add_instance_method(name, location:, visibility: :public)
|
|
60
59
|
instance_methods.add(name.to_sym)
|
|
61
|
-
method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location)
|
|
60
|
+
method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location, visibility)
|
|
62
61
|
end
|
|
63
62
|
|
|
64
|
-
def add_class_method(name, location:)
|
|
63
|
+
def add_class_method(name, location:, visibility: :public)
|
|
65
64
|
class_methods.add(name.to_sym)
|
|
66
|
-
method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location)
|
|
65
|
+
method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location, visibility)
|
|
67
66
|
end
|
|
68
67
|
|
|
69
68
|
def add_mixin(kind, name)
|
|
70
69
|
mixins.fetch(kind).add(name)
|
|
71
70
|
end
|
|
71
|
+
|
|
72
|
+
# Rewrites the visibility of already-recorded definitions, for the
|
|
73
|
+
# <tt>private :foo, :bar</tt> form that names methods defined earlier.
|
|
74
|
+
def set_visibility(name, scope, visibility)
|
|
75
|
+
name = name.to_sym
|
|
76
|
+
method_definitions.map! do |definition|
|
|
77
|
+
definition.name == name && definition.scope == scope ? definition.with(visibility: visibility) : definition
|
|
78
|
+
end
|
|
79
|
+
end
|
|
72
80
|
end
|
|
73
81
|
|
|
74
82
|
Edge = ValueObject.define(:type, :from_path, :from_constant, :to, :location, :confidence, :receiver)
|
|
@@ -117,11 +125,10 @@ module ArchSpec
|
|
|
117
125
|
@components = {}
|
|
118
126
|
end
|
|
119
127
|
|
|
120
|
-
def add_file(path:,
|
|
128
|
+
def add_file(path:, parse_errors:, suppressions: [])
|
|
121
129
|
files[path] = SourceFile.new(
|
|
122
130
|
root: root,
|
|
123
131
|
path: path,
|
|
124
|
-
expected_constant: expected_constant,
|
|
125
132
|
parse_errors: parse_errors,
|
|
126
133
|
suppressions: suppressions
|
|
127
134
|
)
|
|
@@ -157,6 +164,12 @@ module ArchSpec
|
|
|
157
164
|
component.constants.flat_map { |constant_name| constants_named(constant_name) }.flat_map(&:method_definitions)
|
|
158
165
|
end
|
|
159
166
|
|
|
167
|
+
# Every method definition in the graph, across all constants. Used by
|
|
168
|
+
# project-wide naming rules that are not scoped to one component.
|
|
169
|
+
def method_definitions
|
|
170
|
+
constants.flat_map(&:method_definitions)
|
|
171
|
+
end
|
|
172
|
+
|
|
160
173
|
def assign_components(component_specs)
|
|
161
174
|
@components = {}
|
|
162
175
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
4
|
module Rules
|
|
5
|
-
# Backs ArchSpec::DSL::Context#no_cycles
|
|
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
|
|
@@ -41,7 +41,7 @@ module ArchSpec
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
# Backs ArchSpec::DSL::ComponentProxy#
|
|
44
|
+
# Backs ArchSpec::DSL::ComponentProxy#can_only_use. Flags references from the
|
|
45
45
|
# source to any component outside its allowlist.
|
|
46
46
|
class AllowDependenciesRule < DependencyRule
|
|
47
47
|
def id
|
|
@@ -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
|
|
@@ -50,7 +50,7 @@ module ArchSpec
|
|
|
50
50
|
private
|
|
51
51
|
|
|
52
52
|
# A receiverless call to a method the class itself defines (directly,
|
|
53
|
-
# inherited, or via attr_*/delegate) is a call to its own API.
|
|
53
|
+
# inherited, or via attr_*/attribute/delegate) is a call to its own API.
|
|
54
54
|
def own_method_call?(graph, edge)
|
|
55
55
|
return false unless edge.receiver == :none && edge.from_constant
|
|
56
56
|
|
data/lib/archspec/version.rb
CHANGED
data/lib/archspec.rb
CHANGED
|
@@ -12,16 +12,16 @@ 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'
|
|
17
16
|
require_relative 'archspec/rules/concern_rules'
|
|
18
17
|
require_relative 'archspec/rules/dependency_rules'
|
|
18
|
+
require_relative 'archspec/rules/naming_rules'
|
|
19
19
|
require_relative 'archspec/rules/privacy_rule'
|
|
20
20
|
require_relative 'archspec/rules/protocol_rules'
|
|
21
21
|
require_relative 'archspec/rules/cycle_rule'
|
|
22
|
-
require_relative 'archspec/rules/zeitwerk_rule'
|
|
23
22
|
require_relative 'archspec/formatters/text'
|
|
24
23
|
require_relative 'archspec/formatters/json'
|
|
24
|
+
require_relative 'archspec/formatters/explanation'
|
|
25
25
|
require_relative 'archspec/cli'
|
|
26
26
|
|
|
27
27
|
# ArchSpec turns your application's architecture into executable checks.
|
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
|
|
@@ -94,17 +94,17 @@ files:
|
|
|
94
94
|
- lib/archspec/diagnostic.rb
|
|
95
95
|
- lib/archspec/dsl.rb
|
|
96
96
|
- lib/archspec/evaluator.rb
|
|
97
|
+
- lib/archspec/formatters/explanation.rb
|
|
97
98
|
- lib/archspec/formatters/json.rb
|
|
98
99
|
- lib/archspec/formatters/text.rb
|
|
99
100
|
- lib/archspec/model.rb
|
|
100
|
-
- lib/archspec/presets.rb
|
|
101
101
|
- lib/archspec/rules/component_rules.rb
|
|
102
102
|
- lib/archspec/rules/concern_rules.rb
|
|
103
103
|
- lib/archspec/rules/cycle_rule.rb
|
|
104
104
|
- lib/archspec/rules/dependency_rules.rb
|
|
105
|
+
- lib/archspec/rules/naming_rules.rb
|
|
105
106
|
- lib/archspec/rules/privacy_rule.rb
|
|
106
107
|
- lib/archspec/rules/protocol_rules.rb
|
|
107
|
-
- lib/archspec/rules/zeitwerk_rule.rb
|
|
108
108
|
- lib/archspec/source_location.rb
|
|
109
109
|
- lib/archspec/todo.rb
|
|
110
110
|
- lib/archspec/value_object.rb
|
data/lib/archspec/presets.rb
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module ArchSpec
|
|
4
|
-
# Backwards-compatible alias for ArchSpec::Architectures. New code should use
|
|
5
|
-
# +architecture+ in the DSL and the ArchSpec::Architectures module.
|
|
6
|
-
module Presets
|
|
7
|
-
module_function
|
|
8
|
-
|
|
9
|
-
# Delegates to ArchSpec::Architectures.apply.
|
|
10
|
-
def apply(name, dsl, **options)
|
|
11
|
-
Architectures.apply(name, dsl, **options)
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|