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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bee3d8fe834714f4b7fb9837b8bafe96165dde21950884dff5772fc0e4a2793f
4
- data.tar.gz: 0c45ca575799e4928169e931110226c1f23983a8ef1a183108759b2447a566a6
3
+ metadata.gz: 0001aded07b821733e37d7b2cf2adad037d06686ae1ff199510c9f42489027c7
4
+ data.tar.gz: 6c96d3209bbc9616a22664acabf1747117676b9e5e9f8ee7cbe473a40255fbef
5
5
  SHA512:
6
- metadata.gz: d577e26b80aaea4dfe570644bcb6b072b329b4b104f11da16d6f724fbe11080035450b45d5a1f08f1809c2064794a671c037fc50aa27183e2bb06381a2c237a1
7
- data.tar.gz: 7db12b7c471d72275a268a903f81e4184b03b4626539a7db01abf7d01cd6874b2d63575652c04dfe3f89165285a3557c85344c225e86728c0d6a57c99bd87e86
6
+ metadata.gz: 3558e6e242ba00826f4dd86b33113eebf0defda4990aa73e2dc34abed790661ccb005a91768edb0605112e85329510673d900dc9b0148339a01245844bfddcea
7
+ data.tar.gz: 9e1339b47c815d19be1fa994ceec99d7b113af0428dbe62d5221055b161d4668e8c9201cca76f907598d274a0302e4a2196c7fc391a6814e1904590b4766d5f3
data/README.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # ArchSpec
2
2
 
3
- Architecture checks for Ruby and Rails.
3
+ Architecture linter for Ruby and Rails.
4
4
 
5
- Turn your application's architecture into executable checks.
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
- ArchSpec reads Ruby source with Prism, maps conventional Rails files to
8
- constants, and checks the structural rules you write down: components, layers,
9
- constant references, inheritance, mixins, named method calls, method protocols,
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
- # Archspec.rb
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 rich models, no service objects:
42
+ Go vanilla, 37signals style, with rich models and no service objects:
43
43
 
44
44
  ```ruby
45
- ArchSpec.define "Application architecture" do
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
- ArchSpec.define "Application architecture" do
55
- root "."
56
- architecture :layered, layers: {
57
- interface: "app/controllers/**/*.rb",
58
- application: "app/services/**/*.rb",
59
- domain: "app/models/**/*.rb"
60
- }
61
- end
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
- ArchSpec.define "Application architecture" do
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
- ArchSpec.define "Application architecture" do
82
- root "."
83
-
84
- architecture :modular_monolith,
85
- components: {
86
- billing: "packs/billing/**/*.rb",
87
- catalog: "packs/catalog/**/*.rb",
88
- shared: "packs/shared/**/*.rb"
89
- },
90
- allow: {
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
- ArchSpec.define "Application architecture" do
101
- root "."
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
- controllers.can_use :models, :services
108
- models.cannot_use :controllers
109
- services.cannot_call :render, :redirect_to, :params, :session
110
- services.cannot_instantiate_and_invoke
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
- ArchSpec.define "Application architecture" do
118
- root "."
119
-
120
- architecture :cqrs,
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 MVC:** controller APIs kept out of models and services
132
- - **Architectures:** Rails MVC, layered, hexagonal, clean, modular monolith, CQRS, and event-driven bundles
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
@@ -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
- "#{new_receiver_name(receiver)}##{node.message}"
358
- end
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 :layered
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
- ArchSpec.define "Application architecture" do
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
- load absolute_config
111
- definition = ArchSpec.last_definition
112
- raise Error, "#{config_path} did not call ArchSpec.define." unless definition
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
@@ -20,7 +20,7 @@ module ArchSpec
20
20
  attr_accessor :name, :root_path, :baseline_path
21
21
  attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
22
22
 
23
- def initialize(name)
23
+ def initialize(name = nil)
24
24
  @name = name
25
25
  @root_path = '.'
26
26
  @baseline_path = nil
data/lib/archspec/dsl.rb CHANGED
@@ -31,10 +31,6 @@ module ArchSpec
31
31
  alias layer component
32
32
  alias role component
33
33
 
34
- def preset(name, **options)
35
- Presets.apply(name, self, **options)
36
- end
37
-
38
34
  def architecture(name, **options)
39
35
  Architectures.apply(name, self, **options)
40
36
  end
@@ -146,10 +146,10 @@ module ArchSpec
146
146
  end
147
147
 
148
148
  def method_definitions_for_component(name)
149
- component = components.fetch(name.to_sym)
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)
@@ -5,73 +5,7 @@ module ArchSpec
5
5
  module_function
6
6
 
7
7
  def apply(name, dsl, **options)
8
- case name.to_sym
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
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 = 'Architecture', &block)
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.1.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: Convention-aware architecture checks for Ruby and Rails codebases.
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 fitness functions for Ruby and Rails.
117
+ summary: Architecture linter for Ruby and Rails.
116
118
  test_files: []