archspec 0.1.0.pre1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 14c27bcdf86ac400321503fdb1f2a5b904068aafaac65921e00a33a80acea45f
4
+ data.tar.gz: fca658eec0f2f6becfc7159c989ba8ad132c3de0d39c5dfb59561f249afb2407
5
+ SHA512:
6
+ metadata.gz: 4b51c1666d0ec2b27913e796be1969f81b030582b2faf18f9d18f29a1badd7ae1f4b68f2d196a94e976d16fc40e217699a899c6460e037e6a413a68a6aac2299
7
+ data.tar.gz: 8853a35c37af66fd6f18d2aaace5c6e44a22fccd941cff3b00fae5ec739184451329cd639d9cba730a3f925b2a66c232c0e85dbd6b5122c85c91437a17515e1e
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ArchSpec contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # ArchSpec
2
+
3
+ Architecture checks for Ruby and Rails.
4
+
5
+ Turn your application's architecture into executable checks.
6
+
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.
11
+
12
+ It does not try to infer the "true" design pattern of arbitrary Ruby code. You
13
+ describe the architecture your team wants. ArchSpec checks whether the code still
14
+ matches it.
15
+
16
+ ## Why ArchSpec?
17
+
18
+ Architecture usually lives in pull request comments, onboarding docs, and senior
19
+ engineers' heads. That does not scale well, especially when code is moving fast.
20
+
21
+ ArchSpec gives you a small Ruby DSL for the rules that code review otherwise has
22
+ to remember:
23
+
24
+ - models do not reach into controllers
25
+ - domain code does not depend on adapters
26
+ - packs only depend on approved packs
27
+ - query objects do not call obvious write methods
28
+ - generated code follows the same boundaries as hand-written code
29
+
30
+ ## Show me the code
31
+
32
+ Start with conventional Rails boundaries:
33
+
34
+ ```ruby
35
+ # Archspec.rb
36
+ ArchSpec.define "Application architecture" do
37
+ root "."
38
+ preset :rails_way
39
+ end
40
+ ```
41
+
42
+ Add layers when the app has a clear direction of dependencies:
43
+
44
+ ```ruby
45
+ ArchSpec.define "Application architecture" do
46
+ root "."
47
+ architecture :layered, layers: {
48
+ interface: "app/controllers/**/*.rb",
49
+ application: "app/services/**/*.rb",
50
+ domain: "app/models/**/*.rb"
51
+ }
52
+ end
53
+ ```
54
+
55
+ Keep a hexagonal core away from adapters:
56
+
57
+ ```ruby
58
+ ArchSpec.define "Application architecture" do
59
+ root "."
60
+
61
+ architecture :hexagonal,
62
+ application: %w[app/services/**/*.rb app/use_cases/**/*.rb],
63
+ domain: "app/domain/**/*.rb",
64
+ ports: "app/ports/**/*.rb",
65
+ adapters: %w[app/adapters/**/*.rb app/integrations/**/*.rb]
66
+ end
67
+ ```
68
+
69
+ Check a modular monolith:
70
+
71
+ ```ruby
72
+ ArchSpec.define "Application architecture" do
73
+ root "."
74
+
75
+ architecture :modular_monolith,
76
+ components: {
77
+ billing: "packs/billing/**/*.rb",
78
+ catalog: "packs/catalog/**/*.rb",
79
+ shared: "packs/shared/**/*.rb"
80
+ },
81
+ allow: {
82
+ billing: %i[shared],
83
+ catalog: %i[shared]
84
+ }
85
+ end
86
+ ```
87
+
88
+ Write local rules in plain Ruby:
89
+
90
+ ```ruby
91
+ ArchSpec.define "Application architecture" do
92
+ root "."
93
+
94
+ component :controllers, in: "app/controllers/**/*.rb"
95
+ component :models, in: "app/models/**/*.rb"
96
+ component :services, in: "app/services/**/*.rb"
97
+
98
+ controllers.can_use :models, :services
99
+ models.cannot_use :controllers
100
+ services.cannot_call :render, :redirect_to, :params, :session
101
+ services.cannot_instantiate_and_invoke
102
+ end
103
+ ```
104
+
105
+ Check command/query separation:
106
+
107
+ ```ruby
108
+ ArchSpec.define "Application architecture" do
109
+ root "."
110
+
111
+ architecture :cqrs,
112
+ commands: "app/commands/**/*.rb",
113
+ queries: "app/queries/**/*.rb",
114
+ read_models: "app/read_models/**/*.rb"
115
+ end
116
+ ```
117
+
118
+ ## What It Checks
119
+
120
+ - **Dependencies:** allowed and forbidden references between components
121
+ - **Layers:** dependency direction and cycles
122
+ - **Rails MVC:** controller APIs kept out of models and services
123
+ - **Architectures:** Rails MVC, layered, hexagonal, clean, modular monolith, CQRS, and event-driven bundles
124
+ - **Protocols:** required methods such as `resolve`, `perform`, or project-specific interfaces
125
+ - **Objects:** rules against one-shot `Something.new(...).whatever` command objects
126
+ - **Zeitwerk names:** conventional file names defining the expected constants
127
+ - **Suppressions:** narrow local exceptions with a reason
128
+
129
+ ## Installation
130
+
131
+ Add ArchSpec to your Gemfile:
132
+
133
+ ```ruby
134
+ group :development, :test do
135
+ gem "archspec"
136
+ end
137
+ ```
138
+
139
+ Then install it:
140
+
141
+ ```sh
142
+ bundle install
143
+ ```
144
+
145
+ Create `Archspec.rb`:
146
+
147
+ ```sh
148
+ bundle exec archspec init
149
+ ```
150
+
151
+ Run the checks:
152
+
153
+ ```sh
154
+ bundle exec archspec check
155
+ ```
156
+
157
+ ## Commands
158
+
159
+ ```sh
160
+ bundle exec archspec init
161
+ bundle exec archspec check
162
+ bundle exec archspec check --format json
163
+ bundle exec archspec check --update-baseline
164
+ bundle exec archspec explain app/models/user.rb
165
+ ```
166
+
167
+ `explain` shows why a file or constant belongs to a component and which outgoing
168
+ facts ArchSpec found.
169
+
170
+ ## Checking AI-Written Code
171
+
172
+ Generated code should pass the same architecture checks as hand-written code.
173
+
174
+ After an AI-assisted change:
175
+
176
+ ```sh
177
+ bundle exec archspec check
178
+ ```
179
+
180
+ If it fails, read the evidence before changing the spec:
181
+
182
+ ```text
183
+ [dependencies.forbid] app/models/user.rb:2:3
184
+ models must not depend on controllers
185
+ evidence: User references_constant UsersController
186
+ confidence: high
187
+ ```
188
+
189
+ Most failures should be fixed in the generated code. Update the spec only when
190
+ the architecture decision itself has changed.
191
+
192
+ ## Baselines and Suppressions
193
+
194
+ Use a baseline when adopting ArchSpec in an existing app:
195
+
196
+ ```ruby
197
+ baseline ".archspec_todo.yml"
198
+ ```
199
+
200
+ ```sh
201
+ bundle exec archspec check --update-baseline
202
+ ```
203
+
204
+ Use local suppressions for deliberate exceptions:
205
+
206
+ ```ruby
207
+ # archspec:disable-next-line dependencies.forbid -- legacy admin export
208
+ Admin::UsersController
209
+ ```
210
+
211
+ ## Documentation
212
+
213
+ Read the guides at [crmne.github.io/archspec](https://crmne.github.io/archspec/).
214
+
215
+ ## Dogfooding
216
+
217
+ This repository checks its own architecture:
218
+
219
+ ```sh
220
+ bundle exec rake architecture
221
+ ```
222
+
223
+ ## License
224
+
225
+ Released under the MIT License.
data/exe/archspec ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "archspec"
6
+
7
+ exit ArchSpec::CLI.run(ARGV)
@@ -0,0 +1,376 @@
1
+ require "prism"
2
+ require "pathname"
3
+ require "set"
4
+
5
+ module ArchSpec
6
+ module Analyzer
7
+ extend self
8
+
9
+ def analyze(definition, root:)
10
+ root = File.expand_path(root)
11
+ graph = Graph.new(root)
12
+
13
+ ruby_files(definition, root).each do |path|
14
+ result = Prism.parse_file(path)
15
+ graph.add_file(
16
+ path: path,
17
+ expected_constant: expected_constant_for(path, root),
18
+ parse_errors: parse_errors_for(path, result.errors),
19
+ suppressions: suppressions_for(result.comments)
20
+ )
21
+
22
+ SourceVisitor.visit(graph, path, result.value) if result.value
23
+ end
24
+
25
+ graph.assign_components(definition.component_specs.values)
26
+ graph
27
+ end
28
+
29
+ private
30
+
31
+ def ruby_files(definition, root)
32
+ ignored = ignored_files(definition, root)
33
+
34
+ definition.analysis_patterns.flat_map do |pattern|
35
+ Dir.glob(File.absolute_path(pattern, root))
36
+ end.select do |path|
37
+ File.file?(path) && path.end_with?(".rb")
38
+ end.map do |path|
39
+ File.expand_path(path)
40
+ end.uniq.reject do |path|
41
+ ignored.include?(path)
42
+ end.sort
43
+ end
44
+
45
+ def ignored_files(definition, root)
46
+ definition.ignore_patterns.flat_map do |pattern|
47
+ Dir.glob(File.absolute_path(pattern, root))
48
+ end.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }.to_set
49
+ end
50
+
51
+ def expected_constant_for(path, root)
52
+ relative = Pathname(path).relative_path_from(Pathname(root)).to_s
53
+ stem =
54
+ case relative
55
+ when %r{\Aapp/[^/]+/concerns/(.+)\.rb\z}
56
+ Regexp.last_match(1)
57
+ when %r{\Aapp/[^/]+/(.+)\.rb\z}
58
+ Regexp.last_match(1)
59
+ when %r{\Alib/(.+)\.rb\z}
60
+ Regexp.last_match(1)
61
+ when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/(.+)\.rb\z}
62
+ Regexp.last_match(1)
63
+ else
64
+ return nil
65
+ end
66
+
67
+ camelize_path(stem)
68
+ end
69
+
70
+ def camelize_path(path)
71
+ path.split("/").map do |part|
72
+ part.split("_").map { |word| word[0] ? word[0].upcase + word[1..] : word }.join
73
+ end.join("::")
74
+ end
75
+
76
+ def suppressions_for(comments)
77
+ SuppressionParser.parse(comments)
78
+ end
79
+
80
+ def parse_errors_for(path, errors)
81
+ errors.map do |error|
82
+ ParseError.new(error.message, SourceLocation.from_prism(path, error.location))
83
+ end
84
+ end
85
+
86
+ module SuppressionParser
87
+ extend self
88
+
89
+ DISABLE_PATTERN = /\Aarchspec:disable(?:-(next-line|line))?(?:\s+([a-z0-9_.-]+|\*))?(?:\s+--\s*(.+))?\z/i
90
+ ENABLE_PATTERN = /\Aarchspec:enable(?:\s+([a-z0-9_.-]+|\*))?\z/i
91
+
92
+ def parse(comments)
93
+ suppressions = []
94
+ active = Hash.new { |hash, key| hash[key] = [] }
95
+
96
+ sorted_comments(comments).each do |comment|
97
+ text = comment.slice.sub(/\A#\s?/, "").strip
98
+ line = comment.location.start_line
99
+
100
+ if (match = text.match(DISABLE_PATTERN))
101
+ mode, rule, reason = match.captures
102
+ rule = normalize_rule(rule)
103
+
104
+ case mode
105
+ when "line"
106
+ suppressions << Suppression.new(rule, line, line, reason)
107
+ when "next-line"
108
+ suppressions << Suppression.new(rule, line + 1, line + 1, reason)
109
+ else
110
+ active[rule] << [line + 1, reason]
111
+ end
112
+ elsif (match = text.match(ENABLE_PATTERN))
113
+ rule = normalize_rule(match[1])
114
+ if active[rule].any?
115
+ start_line, reason = active[rule].pop
116
+ suppressions << Suppression.new(rule, start_line, [line - 1, start_line].max, reason)
117
+ end
118
+ end
119
+ end
120
+
121
+ active.each do |rule, entries|
122
+ entries.each do |start_line, reason|
123
+ suppressions << Suppression.new(rule, start_line, Float::INFINITY, reason)
124
+ end
125
+ end
126
+
127
+ suppressions
128
+ end
129
+
130
+ private
131
+
132
+ def sorted_comments(comments)
133
+ comments.sort_by { |comment| [comment.location.start_line, comment.location.start_column] }
134
+ end
135
+
136
+ def normalize_rule(rule)
137
+ return nil if rule.nil? || rule == "*"
138
+
139
+ rule.downcase
140
+ end
141
+ end
142
+
143
+ module SourceVisitor
144
+ extend self
145
+
146
+ DYNAMIC_MESSAGES = %i[
147
+ class_eval
148
+ const_get
149
+ const_set
150
+ define_method
151
+ instance_eval
152
+ method_missing
153
+ module_eval
154
+ public_send
155
+ send
156
+ ].freeze
157
+
158
+ MIXIN_MESSAGES = {
159
+ include: :includes,
160
+ prepend: :prepends,
161
+ extend: :extends
162
+ }.freeze
163
+
164
+ def visit(graph, path, node, current_constant: nil, namespace: [])
165
+ return unless node
166
+
167
+ case node
168
+ when Prism::ProgramNode, Prism::StatementsNode
169
+ visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
170
+ when Prism::ClassNode
171
+ visit_class(graph, path, node, current_constant: current_constant, namespace: namespace)
172
+ when Prism::ModuleNode
173
+ visit_module(graph, path, node, current_constant: current_constant, namespace: namespace)
174
+ when Prism::DefNode
175
+ visit_def(graph, path, node, current_constant: current_constant, namespace: namespace)
176
+ when Prism::CallNode
177
+ visit_call(graph, path, node, current_constant: current_constant, namespace: namespace)
178
+ when Prism::ConstantPathNode, Prism::ConstantReadNode
179
+ add_constant_reference(graph, path, node, current_constant)
180
+ else
181
+ visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
182
+ end
183
+ end
184
+
185
+ private
186
+
187
+ def visit_class(graph, path, node, current_constant:, namespace:)
188
+ name = qualified_constant_name(node.constant_path, namespace)
189
+ constant = graph.add_constant(
190
+ name: name,
191
+ kind: :class,
192
+ path: path,
193
+ location: SourceLocation.from_prism(path, node.location)
194
+ )
195
+
196
+ if node.superclass
197
+ superclass = constant_reference_name(node.superclass)
198
+ constant.superclass = superclass
199
+ graph.add_edge(
200
+ type: :inherits_from,
201
+ from_path: path,
202
+ from_constant: constant.name,
203
+ to: superclass,
204
+ location: SourceLocation.from_prism(path, node.superclass.location)
205
+ )
206
+ end
207
+
208
+ visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split("::"))
209
+ end
210
+
211
+ def visit_module(graph, path, node, current_constant:, namespace:)
212
+ name = qualified_constant_name(node.constant_path, namespace)
213
+ constant = graph.add_constant(
214
+ name: name,
215
+ kind: :module,
216
+ path: path,
217
+ location: SourceLocation.from_prism(path, node.location)
218
+ )
219
+
220
+ visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split("::"))
221
+ end
222
+
223
+ def visit_def(graph, path, node, current_constant:, namespace:)
224
+ if current_constant && (constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path })
225
+ if node.receiver
226
+ constant.add_class_method(node.name, location: SourceLocation.from_prism(path, node.location))
227
+ else
228
+ constant.add_instance_method(node.name, location: SourceLocation.from_prism(path, node.location))
229
+ end
230
+ end
231
+
232
+ if node.name == :method_missing
233
+ graph.add_edge(
234
+ type: :dynamic_feature,
235
+ from_path: path,
236
+ from_constant: current_constant,
237
+ to: "method_missing",
238
+ location: SourceLocation.from_prism(path, node.location),
239
+ confidence: :unknown_due_to_dynamic_feature
240
+ )
241
+ end
242
+
243
+ visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
244
+ end
245
+
246
+ def visit_call(graph, path, node, current_constant:, namespace:)
247
+ return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace) unless node.message
248
+
249
+ message = node.message.to_sym
250
+ location = SourceLocation.from_prism(path, node.location)
251
+
252
+ if (one_shot = instantiates_and_invokes(node))
253
+ graph.add_edge(
254
+ type: :instantiates_and_invokes,
255
+ from_path: path,
256
+ from_constant: current_constant,
257
+ to: one_shot,
258
+ location: location
259
+ )
260
+ end
261
+
262
+ if (required = literal_require_argument(node))
263
+ graph.add_edge(
264
+ type: message == :require_relative ? :requires_relative : :requires,
265
+ from_path: path,
266
+ from_constant: current_constant,
267
+ to: required,
268
+ location: location
269
+ )
270
+ end
271
+
272
+ if (edge_type = MIXIN_MESSAGES[message])
273
+ constant_arguments(node).each do |constant_name|
274
+ if current_constant && (constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path })
275
+ constant.add_mixin(message, constant_name)
276
+ end
277
+
278
+ graph.add_edge(
279
+ type: edge_type,
280
+ from_path: path,
281
+ from_constant: current_constant,
282
+ to: constant_name,
283
+ location: location
284
+ )
285
+ end
286
+ end
287
+
288
+ graph.add_edge(
289
+ type: :calls_named_method,
290
+ from_path: path,
291
+ from_constant: current_constant,
292
+ to: message,
293
+ location: location
294
+ )
295
+
296
+ if DYNAMIC_MESSAGES.include?(message)
297
+ graph.add_edge(
298
+ type: :dynamic_feature,
299
+ from_path: path,
300
+ from_constant: current_constant,
301
+ to: message,
302
+ location: location,
303
+ confidence: :unknown_due_to_dynamic_feature
304
+ )
305
+ end
306
+
307
+ visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
308
+ end
309
+
310
+ def add_constant_reference(graph, path, node, current_constant)
311
+ graph.add_edge(
312
+ type: :references_constant,
313
+ from_path: path,
314
+ from_constant: current_constant,
315
+ to: constant_reference_name(node),
316
+ location: SourceLocation.from_prism(path, node.location)
317
+ )
318
+ end
319
+
320
+ def visit_children(graph, path, node, current_constant:, namespace:)
321
+ node.child_nodes.compact.each do |child|
322
+ visit(graph, path, child, current_constant: current_constant, namespace: namespace)
323
+ end
324
+ end
325
+
326
+ def literal_require_argument(node)
327
+ return unless %i[require require_relative].include?(node.message.to_sym)
328
+ return if node.receiver
329
+
330
+ first_argument = node.arguments&.arguments&.first
331
+ return unless first_argument.is_a?(Prism::StringNode)
332
+
333
+ first_argument.unescaped
334
+ end
335
+
336
+ def constant_arguments(node)
337
+ node.arguments&.arguments&.filter_map do |argument|
338
+ constant_reference_name(argument) if constant_node?(argument)
339
+ end || []
340
+ end
341
+
342
+ def instantiates_and_invokes(node)
343
+ receiver = node.receiver
344
+ return unless receiver.is_a?(Prism::CallNode)
345
+ return unless receiver.message == "new"
346
+
347
+ "#{new_receiver_name(receiver)}##{node.message}"
348
+ end
349
+
350
+ def new_receiver_name(node)
351
+ return constant_reference_name(node.receiver) if constant_node?(node.receiver)
352
+
353
+ node.receiver&.slice || "(unknown)"
354
+ end
355
+
356
+ def qualified_constant_name(node, namespace)
357
+ raw = constant_reference_name(node)
358
+ absolute = node.respond_to?(:full_name_parts) && node.full_name_parts.first == :""
359
+
360
+ if absolute || raw.include?("::") || namespace.empty?
361
+ raw
362
+ else
363
+ "#{namespace.join("::")}::#{raw}"
364
+ end
365
+ end
366
+
367
+ def constant_reference_name(node)
368
+ node.full_name.to_s.sub(/\A::/, "")
369
+ end
370
+
371
+ def constant_node?(node)
372
+ node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
373
+ end
374
+ end
375
+ end
376
+ end