archspec 0.1.0 → 0.2.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 +49 -69
- data/lib/archspec/analyzer.rb +3 -9
- data/lib/archspec/architectures.rb +38 -6
- data/lib/archspec/cli.rb +5 -7
- data/lib/archspec/definition.rb +1 -1
- data/lib/archspec/dsl.rb +0 -4
- data/lib/archspec/model.rb +3 -3
- data/lib/archspec/presets.rb +1 -67
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +1 -1
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0001aded07b821733e37d7b2cf2adad037d06686ae1ff199510c9f42489027c7
|
|
4
|
+
data.tar.gz: 6c96d3209bbc9616a22664acabf1747117676b9e5e9f8ee7cbe473a40255fbef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3558e6e242ba00826f4dd86b33113eebf0defda4990aa73e2dc34abed790661ccb005a91768edb0605112e85329510673d900dc9b0148339a01245844bfddcea
|
|
7
|
+
data.tar.gz: 9e1339b47c815d19be1fa994ceec99d7b113af0428dbe62d5221055b161d4668e8c9201cca76f907598d274a0302e4a2196c7fc391a6814e1904590b4766d5f3
|
data/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# ArchSpec
|
|
2
2
|
|
|
3
|
-
Architecture
|
|
3
|
+
Architecture linter for Ruby and Rails.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
ArchSpec turns your application's architecture into executable checks. Declare
|
|
6
|
+
your components, dependencies, and boundaries in one file, then check every
|
|
7
|
+
change in CI, whether a person or a coding agent wrote it. It reads Ruby source
|
|
8
|
+
with Prism and never boots the app.
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
cycles, and Rails boundaries.
|
|
10
|
+
It maps conventional Rails files to constants and checks the structural rules
|
|
11
|
+
you write down: components, layers, constant references, inheritance, mixins,
|
|
12
|
+
named method calls, method protocols, cycles, and Rails boundaries.
|
|
11
13
|
|
|
12
14
|
It does not try to infer the "true" design pattern of arbitrary Ruby code. You
|
|
13
15
|
describe the architecture your team wants. ArchSpec checks whether the code still
|
|
@@ -17,9 +19,11 @@ matches it.
|
|
|
17
19
|
|
|
18
20
|
Architecture usually lives in pull request comments, onboarding docs, and senior
|
|
19
21
|
engineers' heads. That does not scale well, especially when code is moving fast.
|
|
22
|
+
More of that code is now written by coding agents that do not know your
|
|
23
|
+
conventions.
|
|
20
24
|
|
|
21
25
|
ArchSpec gives you a small Ruby DSL for the rules that code review otherwise has
|
|
22
|
-
to remember:
|
|
26
|
+
to remember, and checks them on every change:
|
|
23
27
|
|
|
24
28
|
- models do not reach into controllers
|
|
25
29
|
- domain code does not depend on adapters
|
|
@@ -32,104 +36,80 @@ to remember:
|
|
|
32
36
|
Start with conventional Rails boundaries:
|
|
33
37
|
|
|
34
38
|
```ruby
|
|
35
|
-
|
|
36
|
-
ArchSpec.define "Application architecture" do
|
|
37
|
-
root "."
|
|
38
|
-
preset :rails_way
|
|
39
|
-
end
|
|
39
|
+
architecture :rails
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Go vanilla, 37signals style
|
|
42
|
+
Go vanilla, 37signals style, with rich models and no service objects:
|
|
43
43
|
|
|
44
44
|
```ruby
|
|
45
|
-
|
|
46
|
-
root "."
|
|
47
|
-
preset :vanilla_rails
|
|
48
|
-
end
|
|
45
|
+
architecture :vanilla_rails
|
|
49
46
|
```
|
|
50
47
|
|
|
51
48
|
Add layers when the app has a clear direction of dependencies:
|
|
52
49
|
|
|
53
50
|
```ruby
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
architecture :layered
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Override the default directories when the app uses different names:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
architecture :layered, layers: {
|
|
58
|
+
interface: "app/controllers/**/*.rb",
|
|
59
|
+
application: "app/services/**/*.rb",
|
|
60
|
+
domain: "app/models/**/*.rb"
|
|
61
|
+
}
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
Keep a hexagonal core away from adapters:
|
|
65
65
|
|
|
66
66
|
```ruby
|
|
67
|
-
|
|
68
|
-
root "."
|
|
69
|
-
|
|
70
|
-
architecture :hexagonal,
|
|
71
|
-
application: %w[app/services/**/*.rb app/use_cases/**/*.rb],
|
|
72
|
-
domain: "app/domain/**/*.rb",
|
|
73
|
-
ports: "app/ports/**/*.rb",
|
|
74
|
-
adapters: %w[app/adapters/**/*.rb app/integrations/**/*.rb]
|
|
75
|
-
end
|
|
67
|
+
architecture :hexagonal
|
|
76
68
|
```
|
|
77
69
|
|
|
78
70
|
Check a modular monolith:
|
|
79
71
|
|
|
80
72
|
```ruby
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
billing: %i[shared],
|
|
92
|
-
catalog: %i[shared]
|
|
93
|
-
}
|
|
94
|
-
end
|
|
73
|
+
architecture :modular_monolith,
|
|
74
|
+
components: {
|
|
75
|
+
billing: "packs/billing/**/*.rb",
|
|
76
|
+
catalog: "packs/catalog/**/*.rb",
|
|
77
|
+
shared: "packs/shared/**/*.rb"
|
|
78
|
+
},
|
|
79
|
+
allow: {
|
|
80
|
+
billing: %i[shared],
|
|
81
|
+
catalog: %i[shared]
|
|
82
|
+
}
|
|
95
83
|
```
|
|
96
84
|
|
|
97
85
|
Write local rules in plain Ruby:
|
|
98
86
|
|
|
99
87
|
```ruby
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
component :controllers, in: "app/controllers/**/*.rb"
|
|
104
|
-
component :models, in: "app/models/**/*.rb"
|
|
105
|
-
component :services, in: "app/services/**/*.rb"
|
|
88
|
+
component :controllers, in: "app/controllers/**/*.rb"
|
|
89
|
+
component :models, in: "app/models/**/*.rb"
|
|
90
|
+
component :services, in: "app/services/**/*.rb"
|
|
106
91
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
end
|
|
92
|
+
controllers.can_use :models, :services
|
|
93
|
+
models.cannot_use :controllers
|
|
94
|
+
services.cannot_call :render, :redirect_to, :params, :session
|
|
95
|
+
services.cannot_instantiate_and_invoke
|
|
112
96
|
```
|
|
113
97
|
|
|
114
98
|
Check command/query separation:
|
|
115
99
|
|
|
116
100
|
```ruby
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
commands: "app/commands/**/*.rb",
|
|
122
|
-
queries: "app/queries/**/*.rb",
|
|
123
|
-
read_models: "app/read_models/**/*.rb"
|
|
124
|
-
end
|
|
101
|
+
architecture :cqrs,
|
|
102
|
+
commands: "app/commands/**/*.rb",
|
|
103
|
+
queries: "app/queries/**/*.rb",
|
|
104
|
+
read_models: "app/read_models/**/*.rb"
|
|
125
105
|
```
|
|
126
106
|
|
|
127
107
|
## What It Checks
|
|
128
108
|
|
|
129
109
|
- **Dependencies:** allowed and forbidden references between components
|
|
130
110
|
- **Layers:** dependency direction and cycles
|
|
131
|
-
- **Rails
|
|
132
|
-
- **Architectures:** Rails
|
|
111
|
+
- **Rails:** controller APIs kept out of models and services
|
|
112
|
+
- **Architectures:** Rails, vanilla Rails, layered, hexagonal, clean, modular monolith, CQRS, and event-driven bundles
|
|
133
113
|
- **Protocols:** required methods such as `resolve`, `perform`, or project-specific interfaces
|
|
134
114
|
- **Objects:** rules against one-shot `Something.new(...).whatever` command objects
|
|
135
115
|
- **Zeitwerk names:** conventional file names defining the expected constants
|
data/lib/archspec/analyzer.rb
CHANGED
|
@@ -351,16 +351,10 @@ module ArchSpec
|
|
|
351
351
|
|
|
352
352
|
def instantiates_and_invokes(node)
|
|
353
353
|
receiver = node.receiver
|
|
354
|
-
return unless receiver.is_a?(Prism::CallNode)
|
|
355
|
-
return unless receiver.message == 'new'
|
|
354
|
+
return unless receiver.is_a?(Prism::CallNode) && receiver.message&.to_sym == :new
|
|
356
355
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def new_receiver_name(node)
|
|
361
|
-
return constant_reference_name(node.receiver) if constant_node?(node.receiver)
|
|
362
|
-
|
|
363
|
-
node.receiver&.slice || '(unknown)'
|
|
356
|
+
name = constant_node?(receiver.receiver) ? constant_reference_name(receiver.receiver) : receiver.receiver&.slice
|
|
357
|
+
"#{name}##{node.message}"
|
|
364
358
|
end
|
|
365
359
|
|
|
366
360
|
def qualified_constant_name(node, namespace)
|
|
@@ -45,6 +45,15 @@ module ArchSpec
|
|
|
45
45
|
subscribers: 'app/subscribers/**/*.rb'
|
|
46
46
|
}.freeze
|
|
47
47
|
|
|
48
|
+
VANILLA_RAILS_EMPTY = {
|
|
49
|
+
services: ['app/services/**/*.rb', 'behavior belongs on models, not service objects'],
|
|
50
|
+
forms: ['app/forms/**/*.rb', 'use strong parameters and model validations'],
|
|
51
|
+
policies: ['app/policies/**/*.rb', 'authorization is predicate methods on models'],
|
|
52
|
+
decorators: ['app/decorators/**/*.rb', 'use helpers and ERB partials'],
|
|
53
|
+
presenters: ['app/presenters/**/*.rb', 'presentation objects are POROs in app/models'],
|
|
54
|
+
view_components: ['app/components/**/*.rb', 'use helpers and ERB partials']
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
48
57
|
CONTROLLER_METHODS = %i[render redirect_to params session cookies flash].freeze
|
|
49
58
|
MUTATING_METHODS = %i[
|
|
50
59
|
create create!
|
|
@@ -58,19 +67,27 @@ module ArchSpec
|
|
|
58
67
|
|
|
59
68
|
def apply(name, dsl, **options)
|
|
60
69
|
case name.to_sym
|
|
61
|
-
when :rails_mvc, :rails_way
|
|
70
|
+
when :rails, :rails_mvc, :rails_way
|
|
62
71
|
rails_mvc(dsl, components: options.fetch(:components, DEFAULT_RAILS_MVC))
|
|
63
|
-
when :
|
|
72
|
+
when :rails_strict
|
|
73
|
+
rails_strict(dsl, components: options.fetch(:components, DEFAULT_RAILS_MVC))
|
|
74
|
+
when :vanilla_rails
|
|
75
|
+
vanilla_rails(
|
|
76
|
+
dsl,
|
|
77
|
+
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
78
|
+
empty: options.fetch(:empty, VANILLA_RAILS_EMPTY)
|
|
79
|
+
)
|
|
80
|
+
when :layered, :rails_layered
|
|
64
81
|
layered(dsl, layers: options.fetch(:layers, DEFAULT_LAYERED))
|
|
65
|
-
when :hexagonal
|
|
82
|
+
when :hexagonal, :rails_hexagonal
|
|
66
83
|
hexagonal(dsl, **with_defaults(DEFAULT_HEXAGONAL, options))
|
|
67
|
-
when :clean
|
|
84
|
+
when :clean, :rails_clean
|
|
68
85
|
clean(dsl, **with_defaults(DEFAULT_CLEAN, options))
|
|
69
86
|
when :modular_monolith, :bounded_contexts
|
|
70
87
|
modular_monolith(dsl, components: options.fetch(:components), allow: options.fetch(:allow, {}))
|
|
71
|
-
when :cqrs
|
|
88
|
+
when :cqrs, :rails_cqrs
|
|
72
89
|
cqrs(dsl, **with_defaults(DEFAULT_CQRS, options))
|
|
73
|
-
when :event_driven
|
|
90
|
+
when :event_driven, :rails_event_driven
|
|
74
91
|
event_driven(dsl, **with_defaults(DEFAULT_EVENT_DRIVEN, options))
|
|
75
92
|
else
|
|
76
93
|
raise Error, "Unknown ArchSpec architecture: #{name.inspect}"
|
|
@@ -88,6 +105,21 @@ module ArchSpec
|
|
|
88
105
|
proxy_for(dsl, :services).cannot_call(*CONTROLLER_METHODS)
|
|
89
106
|
end
|
|
90
107
|
|
|
108
|
+
def rails_strict(dsl, components:)
|
|
109
|
+
components = normalize_map(components)
|
|
110
|
+
rails_mvc(dsl, components: components)
|
|
111
|
+
dsl.verify_zeitwerk_names!
|
|
112
|
+
dsl.no_cycles!(among: components.keys)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def vanilla_rails(dsl, components:, empty:)
|
|
116
|
+
rails_mvc(dsl, components: components)
|
|
117
|
+
|
|
118
|
+
empty.each do |name, (pattern, reason)|
|
|
119
|
+
dsl.component(name, in: pattern).must_be_empty(because: reason)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
91
123
|
def layered(dsl, layers:)
|
|
92
124
|
ordered = normalize_map(layers)
|
|
93
125
|
define_components(dsl, ordered)
|
data/lib/archspec/cli.rb
CHANGED
|
@@ -8,10 +8,7 @@ module ArchSpec
|
|
|
8
8
|
|
|
9
9
|
CONFIG_FILE = 'Archspec.rb'
|
|
10
10
|
TEMPLATE = <<~RUBY
|
|
11
|
-
|
|
12
|
-
root "."
|
|
13
|
-
preset :rails_way
|
|
14
|
-
end
|
|
11
|
+
architecture :rails
|
|
15
12
|
RUBY
|
|
16
13
|
|
|
17
14
|
def run(argv, output: $stdout, error: $stderr)
|
|
@@ -107,9 +104,10 @@ module ArchSpec
|
|
|
107
104
|
|
|
108
105
|
ArchSpec.last_definition = nil
|
|
109
106
|
absolute_config = File.expand_path(config_path)
|
|
110
|
-
|
|
111
|
-
definition
|
|
112
|
-
|
|
107
|
+
definition = Definition.new
|
|
108
|
+
definition.extend(DSL::Context)
|
|
109
|
+
definition.instance_eval(File.read(absolute_config), absolute_config)
|
|
110
|
+
definition = ArchSpec.last_definition || definition
|
|
113
111
|
|
|
114
112
|
[definition, definition.absolute_root(File.dirname(absolute_config))]
|
|
115
113
|
end
|
data/lib/archspec/definition.rb
CHANGED
data/lib/archspec/dsl.rb
CHANGED
data/lib/archspec/model.rb
CHANGED
|
@@ -146,10 +146,10 @@ module ArchSpec
|
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
def method_definitions_for_component(name)
|
|
149
|
-
component = components
|
|
149
|
+
component = components[name.to_sym]
|
|
150
|
+
return [] unless component
|
|
151
|
+
|
|
150
152
|
component.constants.flat_map { |constant_name| constants_named(constant_name) }.flat_map(&:method_definitions)
|
|
151
|
-
rescue KeyError
|
|
152
|
-
[]
|
|
153
153
|
end
|
|
154
154
|
|
|
155
155
|
def assign_components(component_specs)
|
data/lib/archspec/presets.rb
CHANGED
|
@@ -5,73 +5,7 @@ module ArchSpec
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def apply(name, dsl, **options)
|
|
8
|
-
|
|
9
|
-
when :rails_way, :rails_mvc
|
|
10
|
-
rails_way(dsl, **options)
|
|
11
|
-
when :rails_strict
|
|
12
|
-
rails_strict(dsl, **options)
|
|
13
|
-
when :vanilla_rails
|
|
14
|
-
vanilla_rails(dsl, **options)
|
|
15
|
-
when :rails_layered
|
|
16
|
-
rails_layered(dsl, **options)
|
|
17
|
-
when :rails_hexagonal
|
|
18
|
-
rails_hexagonal(dsl, **options)
|
|
19
|
-
when :rails_clean
|
|
20
|
-
rails_clean(dsl, **options)
|
|
21
|
-
when :rails_cqrs
|
|
22
|
-
rails_cqrs(dsl, **options)
|
|
23
|
-
when :rails_event_driven
|
|
24
|
-
rails_event_driven(dsl, **options)
|
|
25
|
-
else
|
|
26
|
-
raise Error, "Unknown ArchSpec preset: #{name.inspect}"
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def rails_way(dsl, **options)
|
|
31
|
-
Architectures.apply(:rails_mvc, dsl, **options)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def rails_strict(dsl, **options)
|
|
35
|
-
rails_way(dsl, **options)
|
|
36
|
-
dsl.verify_zeitwerk_names!
|
|
37
|
-
dsl.no_cycles!(among: %i[controllers models helpers mailers jobs services])
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
VANILLA_RAILS_EMPTY = {
|
|
41
|
-
services: ['app/services/**/*.rb', 'behavior belongs on models, not service objects'],
|
|
42
|
-
forms: ['app/forms/**/*.rb', 'use strong parameters and model validations'],
|
|
43
|
-
policies: ['app/policies/**/*.rb', 'authorization is predicate methods on models'],
|
|
44
|
-
decorators: ['app/decorators/**/*.rb', 'use helpers and partials'],
|
|
45
|
-
presenters: ['app/presenters/**/*.rb', 'presentation objects are POROs in app/models'],
|
|
46
|
-
view_components: ['app/components/**/*.rb', 'use helpers and ERB partials']
|
|
47
|
-
}.freeze
|
|
48
|
-
|
|
49
|
-
def vanilla_rails(dsl, **options)
|
|
50
|
-
rails_way(dsl, **options)
|
|
51
|
-
|
|
52
|
-
VANILLA_RAILS_EMPTY.each do |name, (pattern, reason)|
|
|
53
|
-
dsl.component(name, in: pattern).must_be_empty(because: reason)
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def rails_layered(dsl, **options)
|
|
58
|
-
Architectures.apply(:layered, dsl, **options)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def rails_hexagonal(dsl, **options)
|
|
62
|
-
Architectures.apply(:hexagonal, dsl, **options)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def rails_clean(dsl, **options)
|
|
66
|
-
Architectures.apply(:clean, dsl, **options)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def rails_cqrs(dsl, **options)
|
|
70
|
-
Architectures.apply(:cqrs, dsl, **options)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def rails_event_driven(dsl, **options)
|
|
74
|
-
Architectures.apply(:event_driven, dsl, **options)
|
|
8
|
+
Architectures.apply(name, dsl, **options)
|
|
75
9
|
end
|
|
76
10
|
end
|
|
77
11
|
end
|
data/lib/archspec/version.rb
CHANGED
data/lib/archspec.rb
CHANGED
|
@@ -27,7 +27,7 @@ module ArchSpec
|
|
|
27
27
|
class << self
|
|
28
28
|
attr_accessor :last_definition
|
|
29
29
|
|
|
30
|
-
def define(name =
|
|
30
|
+
def define(name = nil, &block)
|
|
31
31
|
definition = Definition.new(name)
|
|
32
32
|
definition.extend(DSL::Context)
|
|
33
33
|
definition.instance_eval(&block) if block
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: archspec
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Carmine Paolino
|
|
@@ -52,7 +52,9 @@ dependencies:
|
|
|
52
52
|
- - ">="
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '13.0'
|
|
55
|
-
description:
|
|
55
|
+
description: A static architecture linter for Ruby and Rails. Declare your components,
|
|
56
|
+
dependencies, and boundaries in one file, then check every change in CI. It reads
|
|
57
|
+
source with Prism and never boots the app.
|
|
56
58
|
email:
|
|
57
59
|
- carmine@paolino.me
|
|
58
60
|
executables:
|
|
@@ -112,5 +114,5 @@ requirements: []
|
|
|
112
114
|
rubygems_version: 3.5.22
|
|
113
115
|
signing_key:
|
|
114
116
|
specification_version: 4
|
|
115
|
-
summary: Architecture
|
|
117
|
+
summary: Architecture linter for Ruby and Rails.
|
|
116
118
|
test_files: []
|