archspec 0.3.0 → 0.4.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 +23 -8
- data/lib/archspec/analyzer.rb +94 -18
- data/lib/archspec/architectures.rb +91 -14
- data/lib/archspec/cli.rb +41 -15
- data/lib/archspec/component_spec.rb +3 -0
- data/lib/archspec/definition.rb +18 -5
- data/lib/archspec/diagnostic.rb +7 -1
- data/lib/archspec/dsl.rb +219 -6
- data/lib/archspec/evaluator.rb +4 -3
- data/lib/archspec/model.rb +38 -3
- data/lib/archspec/presets.rb +3 -0
- 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/privacy_rule.rb +81 -0
- data/lib/archspec/rules/protocol_rules.rb +62 -13
- data/lib/archspec/rules/zeitwerk_rule.rb +24 -0
- data/lib/archspec/{baseline.rb → todo.rb} +5 -1
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +38 -1
- metadata +19 -3
|
@@ -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_*/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
|
|
|
@@ -2,14 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
4
|
module Rules
|
|
5
|
+
# Backs ArchSpec::DSL::Context#verify_zeitwerk_names!. Flags files that do
|
|
6
|
+
# not define the constant their path implies under Zeitwerk.
|
|
5
7
|
class ZeitwerkNamingRule
|
|
8
|
+
attr_reader :only
|
|
9
|
+
|
|
10
|
+
# only: globs restricting which files are checked. Default is every file
|
|
11
|
+
# ArchSpec can name, but lib autoloading is opt-in, so apps that require
|
|
12
|
+
# lib manually scope this to app/.
|
|
13
|
+
def initialize(only: nil)
|
|
14
|
+
@only = Array(only).flatten.compact.map(&:to_s)
|
|
15
|
+
end
|
|
16
|
+
|
|
6
17
|
def id
|
|
7
18
|
'zeitwerk.naming'
|
|
8
19
|
end
|
|
9
20
|
|
|
10
21
|
def evaluate(graph)
|
|
22
|
+
scoped_paths = scoped_paths(graph)
|
|
23
|
+
|
|
11
24
|
graph.files.values.filter_map do |file|
|
|
12
25
|
next unless file.expected_constant
|
|
26
|
+
next if scoped_paths && !scoped_paths.include?(file.path)
|
|
13
27
|
|
|
14
28
|
defined = graph.constants_for_path(file.path).map(&:name)
|
|
15
29
|
next if defined.include?(file.expected_constant)
|
|
@@ -22,6 +36,16 @@ module ArchSpec
|
|
|
22
36
|
)
|
|
23
37
|
end
|
|
24
38
|
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def scoped_paths(graph)
|
|
43
|
+
return nil if only.empty?
|
|
44
|
+
|
|
45
|
+
only.flat_map { |pattern| Dir.glob(File.absolute_path(pattern, graph.root)) }
|
|
46
|
+
.map { |path| File.expand_path(path) }
|
|
47
|
+
.to_set
|
|
48
|
+
end
|
|
25
49
|
end
|
|
26
50
|
end
|
|
27
51
|
end
|
|
@@ -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,14 +7,16 @@ 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
15
|
require_relative 'archspec/presets'
|
|
16
16
|
require_relative 'archspec/rules/component_rules'
|
|
17
|
+
require_relative 'archspec/rules/concern_rules'
|
|
17
18
|
require_relative 'archspec/rules/dependency_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
22
|
require_relative 'archspec/rules/zeitwerk_rule'
|
|
@@ -22,12 +24,47 @@ require_relative 'archspec/formatters/text'
|
|
|
22
24
|
require_relative 'archspec/formatters/json'
|
|
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.4.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-07-05 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,7 +88,6 @@ 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
|
|
@@ -86,11 +99,14 @@ files:
|
|
|
86
99
|
- lib/archspec/model.rb
|
|
87
100
|
- 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/privacy_rule.rb
|
|
91
106
|
- lib/archspec/rules/protocol_rules.rb
|
|
92
107
|
- 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
|