archspec 0.1.0.pre1 → 0.1.0.pre2

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: 14c27bcdf86ac400321503fdb1f2a5b904068aafaac65921e00a33a80acea45f
4
- data.tar.gz: fca658eec0f2f6becfc7159c989ba8ad132c3de0d39c5dfb59561f249afb2407
3
+ metadata.gz: e3056236344cd4f0ba0b95312353e866c6186313878d0be016f96191e04bd011
4
+ data.tar.gz: f8fbeea8100183e9258206e227700fcf1a8a6027e53bd57970f495be29addbc7
5
5
  SHA512:
6
- metadata.gz: 4b51c1666d0ec2b27913e796be1969f81b030582b2faf18f9d18f29a1badd7ae1f4b68f2d196a94e976d16fc40e217699a899c6460e037e6a413a68a6aac2299
7
- data.tar.gz: 8853a35c37af66fd6f18d2aaace5c6e44a22fccd941cff3b00fae5ec739184451329cd639d9cba730a3f925b2a66c232c0e85dbd6b5122c85c91437a17515e1e
6
+ metadata.gz: 20640ab2eb6f87cbd911f5270c3b2b6af99ea7841c3ead3c6c5aee51601bafcf4c5e835867a749edf1e59a89100a345693eda407a69f850b7b7ceed2d556adcb
7
+ data.tar.gz: 2369c0d26ba6c88fb13d0e7e837bbb8fadffc4391a5ccc7239b59f895683d22c79079a9f6d4f1034aff6882b5c1ab9bf21d68c2f42d0d95c140950f657dd8cb3
data/README.md CHANGED
@@ -39,6 +39,15 @@ ArchSpec.define "Application architecture" do
39
39
  end
40
40
  ```
41
41
 
42
+ Go vanilla, 37signals style — rich models, no service objects:
43
+
44
+ ```ruby
45
+ ArchSpec.define "Application architecture" do
46
+ root "."
47
+ preset :vanilla_rails
48
+ end
49
+ ```
50
+
42
51
  Add layers when the app has a clear direction of dependencies:
43
52
 
44
53
  ```ruby
@@ -124,6 +133,7 @@ end
124
133
  - **Protocols:** required methods such as `resolve`, `perform`, or project-specific interfaces
125
134
  - **Objects:** rules against one-shot `Something.new(...).whatever` command objects
126
135
  - **Zeitwerk names:** conventional file names defining the expected constants
136
+ - **Empty components:** directories that must stay empty, like `app/services` in vanilla Rails
127
137
  - **Suppressions:** narrow local exceptions with a reason
128
138
 
129
139
  ## Installation
data/exe/archspec CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
- $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
4
5
 
5
- require "archspec"
6
+ require 'archspec'
6
7
 
7
8
  exit ArchSpec::CLI.run(ARGV)
@@ -1,6 +1,7 @@
1
- require "prism"
2
- require "pathname"
3
- require "set"
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+ require 'pathname'
4
5
 
5
6
  module ArchSpec
6
7
  module Analyzer
@@ -34,7 +35,7 @@ module ArchSpec
34
35
  definition.analysis_patterns.flat_map do |pattern|
35
36
  Dir.glob(File.absolute_path(pattern, root))
36
37
  end.select do |path|
37
- File.file?(path) && path.end_with?(".rb")
38
+ File.file?(path) && path.end_with?('.rb')
38
39
  end.map do |path|
39
40
  File.expand_path(path)
40
41
  end.uniq.reject do |path|
@@ -58,6 +59,8 @@ module ArchSpec
58
59
  Regexp.last_match(1)
59
60
  when %r{\Alib/(.+)\.rb\z}
60
61
  Regexp.last_match(1)
62
+ when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/concerns/(.+)\.rb\z}
63
+ Regexp.last_match(1)
61
64
  when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/(.+)\.rb\z}
62
65
  Regexp.last_match(1)
63
66
  else
@@ -68,9 +71,9 @@ module ArchSpec
68
71
  end
69
72
 
70
73
  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
+ path.split('/').map do |part|
75
+ part.split('_').map { |word| word[0] ? word[0].upcase + word[1..] : word }.join
76
+ end.join('::')
74
77
  end
75
78
 
76
79
  def suppressions_for(comments)
@@ -94,7 +97,7 @@ module ArchSpec
94
97
  active = Hash.new { |hash, key| hash[key] = [] }
95
98
 
96
99
  sorted_comments(comments).each do |comment|
97
- text = comment.slice.sub(/\A#\s?/, "").strip
100
+ text = comment.slice.sub(/\A#\s?/, '').strip
98
101
  line = comment.location.start_line
99
102
 
100
103
  if (match = text.match(DISABLE_PATTERN))
@@ -102,9 +105,9 @@ module ArchSpec
102
105
  rule = normalize_rule(rule)
103
106
 
104
107
  case mode
105
- when "line"
108
+ when 'line'
106
109
  suppressions << Suppression.new(rule, line, line, reason)
107
- when "next-line"
110
+ when 'next-line'
108
111
  suppressions << Suppression.new(rule, line + 1, line + 1, reason)
109
112
  else
110
113
  active[rule] << [line + 1, reason]
@@ -134,7 +137,7 @@ module ArchSpec
134
137
  end
135
138
 
136
139
  def normalize_rule(rule)
137
- return nil if rule.nil? || rule == "*"
140
+ return nil if rule.nil? || rule == '*'
138
141
 
139
142
  rule.downcase
140
143
  end
@@ -205,7 +208,7 @@ module ArchSpec
205
208
  )
206
209
  end
207
210
 
208
- visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split("::"))
211
+ visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
209
212
  end
210
213
 
211
214
  def visit_module(graph, path, node, current_constant:, namespace:)
@@ -217,11 +220,13 @@ module ArchSpec
217
220
  location: SourceLocation.from_prism(path, node.location)
218
221
  )
219
222
 
220
- visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split("::"))
223
+ visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split('::'))
221
224
  end
222
225
 
223
226
  def visit_def(graph, path, node, current_constant:, namespace:)
224
- if current_constant && (constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path })
227
+ if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
228
+ candidate.path == path
229
+ end)
225
230
  if node.receiver
226
231
  constant.add_class_method(node.name, location: SourceLocation.from_prism(path, node.location))
227
232
  else
@@ -234,7 +239,7 @@ module ArchSpec
234
239
  type: :dynamic_feature,
235
240
  from_path: path,
236
241
  from_constant: current_constant,
237
- to: "method_missing",
242
+ to: 'method_missing',
238
243
  location: SourceLocation.from_prism(path, node.location),
239
244
  confidence: :unknown_due_to_dynamic_feature
240
245
  )
@@ -244,7 +249,10 @@ module ArchSpec
244
249
  end
245
250
 
246
251
  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
252
+ unless node.message
253
+ return visit_children(graph, path, node, current_constant: current_constant,
254
+ namespace: namespace)
255
+ end
248
256
 
249
257
  message = node.message.to_sym
250
258
  location = SourceLocation.from_prism(path, node.location)
@@ -271,7 +279,9 @@ module ArchSpec
271
279
 
272
280
  if (edge_type = MIXIN_MESSAGES[message])
273
281
  constant_arguments(node).each do |constant_name|
274
- if current_constant && (constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path })
282
+ if current_constant && (constant = graph.constants_named(current_constant).find do |candidate|
283
+ candidate.path == path
284
+ end)
275
285
  constant.add_mixin(message, constant_name)
276
286
  end
277
287
 
@@ -342,7 +352,7 @@ module ArchSpec
342
352
  def instantiates_and_invokes(node)
343
353
  receiver = node.receiver
344
354
  return unless receiver.is_a?(Prism::CallNode)
345
- return unless receiver.message == "new"
355
+ return unless receiver.message == 'new'
346
356
 
347
357
  "#{new_receiver_name(receiver)}##{node.message}"
348
358
  end
@@ -350,22 +360,22 @@ module ArchSpec
350
360
  def new_receiver_name(node)
351
361
  return constant_reference_name(node.receiver) if constant_node?(node.receiver)
352
362
 
353
- node.receiver&.slice || "(unknown)"
363
+ node.receiver&.slice || '(unknown)'
354
364
  end
355
365
 
356
366
  def qualified_constant_name(node, namespace)
357
367
  raw = constant_reference_name(node)
358
368
  absolute = node.respond_to?(:full_name_parts) && node.full_name_parts.first == :""
359
369
 
360
- if absolute || raw.include?("::") || namespace.empty?
370
+ if absolute || raw.include?('::') || namespace.empty?
361
371
  raw
362
372
  else
363
- "#{namespace.join("::")}::#{raw}"
373
+ "#{namespace.join('::')}::#{raw}"
364
374
  end
365
375
  end
366
376
 
367
377
  def constant_reference_name(node)
368
- node.full_name.to_s.sub(/\A::/, "")
378
+ node.full_name.to_s.sub(/\A::/, '')
369
379
  end
370
380
 
371
381
  def constant_node?(node)
@@ -1,26 +1,28 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  module Architectures
3
5
  extend self
4
6
 
5
7
  DEFAULT_LAYERED = {
6
- interface: "app/controllers/**/*.rb",
8
+ interface: 'app/controllers/**/*.rb',
7
9
  application: %w[app/services/**/*.rb app/jobs/**/*.rb app/mailers/**/*.rb],
8
- domain: "app/models/**/*.rb"
10
+ domain: 'app/models/**/*.rb'
9
11
  }.freeze
10
12
 
11
13
  DEFAULT_RAILS_MVC = {
12
- controllers: "app/controllers/**/*.rb",
13
- models: "app/models/**/*.rb",
14
- helpers: "app/helpers/**/*.rb",
15
- mailers: "app/mailers/**/*.rb",
16
- jobs: "app/jobs/**/*.rb",
17
- services: "app/services/**/*.rb"
14
+ controllers: 'app/controllers/**/*.rb',
15
+ models: 'app/models/**/*.rb',
16
+ helpers: 'app/helpers/**/*.rb',
17
+ mailers: 'app/mailers/**/*.rb',
18
+ jobs: 'app/jobs/**/*.rb',
19
+ services: 'app/services/**/*.rb'
18
20
  }.freeze
19
21
 
20
22
  DEFAULT_HEXAGONAL = {
21
23
  application: %w[app/services/**/*.rb app/use_cases/**/*.rb],
22
- domain: "app/domain/**/*.rb",
23
- ports: "app/ports/**/*.rb",
24
+ domain: 'app/domain/**/*.rb',
25
+ ports: 'app/ports/**/*.rb',
24
26
  adapters: %w[app/adapters/**/*.rb app/integrations/**/*.rb app/infrastructure/**/*.rb]
25
27
  }.freeze
26
28
 
@@ -32,15 +34,15 @@ module ArchSpec
32
34
  }.freeze
33
35
 
34
36
  DEFAULT_CQRS = {
35
- commands: "app/commands/**/*.rb",
36
- queries: "app/queries/**/*.rb",
37
- read_models: "app/read_models/**/*.rb"
37
+ commands: 'app/commands/**/*.rb',
38
+ queries: 'app/queries/**/*.rb',
39
+ read_models: 'app/read_models/**/*.rb'
38
40
  }.freeze
39
41
 
40
42
  DEFAULT_EVENT_DRIVEN = {
41
- events: "app/events/**/*.rb",
42
- publishers: "app/publishers/**/*.rb",
43
- subscribers: "app/subscribers/**/*.rb"
43
+ events: 'app/events/**/*.rb',
44
+ publishers: 'app/publishers/**/*.rb',
45
+ subscribers: 'app/subscribers/**/*.rb'
44
46
  }.freeze
45
47
 
46
48
  CONTROLLER_METHODS = %i[render redirect_to params session cookies flash].freeze
@@ -1,5 +1,6 @@
1
- require "set"
2
- require "yaml"
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
3
4
 
4
5
  module ArchSpec
5
6
  class Baseline
@@ -11,8 +12,8 @@ module ArchSpec
11
12
  return empty(root: root) unless path && File.exist?(path)
12
13
 
13
14
  document = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
14
- ids = Array(document["violations"]).filter_map do |entry|
15
- entry.is_a?(Hash) ? entry["id"] : entry
15
+ ids = Array(document['violations']).filter_map do |entry|
16
+ entry.is_a?(Hash) ? entry['id'] : entry
16
17
  end
17
18
 
18
19
  new(ids.to_set, root: root)
@@ -20,13 +21,13 @@ module ArchSpec
20
21
 
21
22
  def self.write(path, diagnostics, root:)
22
23
  payload = {
23
- "violations" => diagnostics.map do |diagnostic|
24
+ 'violations' => diagnostics.map do |diagnostic|
24
25
  {
25
- "id" => diagnostic.fingerprint(root: root),
26
- "rule" => diagnostic.rule,
27
- "path" => diagnostic.location.relative_path(root),
28
- "line" => diagnostic.location.line,
29
- "message" => diagnostic.message
26
+ 'id' => diagnostic.fingerprint(root: root),
27
+ 'rule' => diagnostic.rule,
28
+ 'path' => diagnostic.location.relative_path(root),
29
+ 'line' => diagnostic.location.line,
30
+ 'message' => diagnostic.message
30
31
  }
31
32
  end
32
33
  }
data/lib/archspec/cli.rb CHANGED
@@ -1,10 +1,12 @@
1
- require "optparse"
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
2
4
 
3
5
  module ArchSpec
4
6
  module CLI
5
7
  extend self
6
8
 
7
- CONFIG_FILE = "Archspec.rb"
9
+ CONFIG_FILE = 'Archspec.rb'
8
10
  TEMPLATE = <<~RUBY
9
11
  ArchSpec.define "Application architecture" do
10
12
  root "."
@@ -14,16 +16,16 @@ module ArchSpec
14
16
 
15
17
  def run(argv, output: $stdout, error: $stderr)
16
18
  argv = argv.dup
17
- command = argv.shift || "check"
19
+ command = argv.shift || 'check'
18
20
 
19
21
  case command
20
- when "init"
22
+ when 'init'
21
23
  init(argv, output)
22
- when "check"
24
+ when 'check'
23
25
  check(argv, output)
24
- when "explain"
26
+ when 'explain'
25
27
  explain(argv, output)
26
- when "version", "--version", "-v"
28
+ when 'version', '--version', '-v'
27
29
  output.puts ArchSpec::VERSION
28
30
  0
29
31
  else
@@ -31,20 +33,18 @@ module ArchSpec
31
33
  error.puts usage
32
34
  64
33
35
  end
34
- rescue Error => exception
35
- error.puts exception.message
36
+ rescue Error => e
37
+ error.puts e.message
36
38
  1
37
39
  end
38
40
 
39
41
  private
40
42
 
41
43
  def init(argv, output)
42
- force = argv.delete("--force")
44
+ force = argv.delete('--force')
43
45
  path = argv.shift || CONFIG_FILE
44
46
 
45
- if File.exist?(path) && !force
46
- raise Error, "#{path} already exists. Use --force to overwrite it."
47
- end
47
+ raise Error, "#{path} already exists. Use --force to overwrite it." if File.exist?(path) && !force
48
48
 
49
49
  File.write(path, TEMPLATE)
50
50
  output.puts "Created #{path}"
@@ -54,14 +54,14 @@ module ArchSpec
54
54
  def check(argv, output)
55
55
  options = {
56
56
  config: CONFIG_FILE,
57
- format: "text",
57
+ format: 'text',
58
58
  update_baseline: false
59
59
  }
60
60
 
61
- parser = OptionParser.new do |parser|
62
- parser.on("--config PATH") { |value| options[:config] = value }
63
- parser.on("--format FORMAT") { |value| options[:format] = value }
64
- parser.on("--update-baseline") { options[:update_baseline] = true }
61
+ parser = OptionParser.new do |opts|
62
+ opts.on('--config PATH') { |value| options[:config] = value }
63
+ opts.on('--format FORMAT') { |value| options[:format] = value }
64
+ opts.on('--update-baseline') { options[:update_baseline] = true }
65
65
  end
66
66
  parser.parse!(argv)
67
67
 
@@ -72,7 +72,10 @@ module ArchSpec
72
72
  diagnostics = Evaluator.evaluate(definition, graph, baseline: baseline)
73
73
 
74
74
  if options[:update_baseline]
75
- raise Error, "No baseline configured. Add `baseline \".archspec_todo.yml\"` to #{options[:config]}." unless baseline_path
75
+ unless baseline_path
76
+ raise Error,
77
+ "No baseline configured. Add `baseline \".archspec_todo.yml\"` to #{options[:config]}."
78
+ end
76
79
 
77
80
  Baseline.write(baseline_path, diagnostics, root: root)
78
81
  output.puts "Updated #{Pathname(baseline_path).relative_path_from(Pathname(root))} with #{diagnostics.size} violations."
@@ -85,13 +88,13 @@ module ArchSpec
85
88
 
86
89
  def explain(argv, output)
87
90
  options = { config: CONFIG_FILE }
88
- parser = OptionParser.new do |parser|
89
- parser.on("--config PATH") { |value| options[:config] = value }
91
+ parser = OptionParser.new do |opts|
92
+ opts.on('--config PATH') { |value| options[:config] = value }
90
93
  end
91
94
  parser.parse!(argv)
92
95
 
93
96
  subject = argv.shift
94
- raise Error, "Usage: archspec explain PATH_OR_CONSTANT" unless subject
97
+ raise Error, 'Usage: archspec explain PATH_OR_CONSTANT' unless subject
95
98
 
96
99
  definition, root = load_definition(options[:config])
97
100
  graph = Analyzer.analyze(definition, root: root)
@@ -119,9 +122,9 @@ module ArchSpec
119
122
 
120
123
  def formatter_for(name)
121
124
  case name
122
- when "text"
125
+ when 'text'
123
126
  Formatters::Text
124
- when "json"
127
+ when 'json'
125
128
  Formatters::JSON
126
129
  else
127
130
  raise Error, "Unknown format: #{name.inspect}"
@@ -134,12 +137,12 @@ module ArchSpec
134
137
  if graph.files.key?(path)
135
138
  file = graph.files.fetch(path)
136
139
  output.puts file.relative_path
137
- output.puts " expected constant: #{file.expected_constant || "(none)"}"
138
- output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(", ")}"
140
+ output.puts " expected constant: #{file.expected_constant || '(none)'}"
141
+ output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(', ')}"
139
142
  output_parse_errors(output, file)
140
143
  output_component_reasons(output, graph.component_assignment_reasons_for_path(path))
141
144
  output_suppressions(output, file)
142
- output.puts " outgoing facts:"
145
+ output.puts ' outgoing facts:'
143
146
 
144
147
  graph.edges.select { |edge| edge.from_path == path }.each do |edge|
145
148
  output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
@@ -153,29 +156,29 @@ module ArchSpec
153
156
  output.puts " kind: #{constant.kind}"
154
157
  output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
155
158
  output_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
156
- output.puts " superclass: #{constant.superclass || "(none)"}"
157
- output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(", ")}"
158
- output.puts " class methods: #{constant.class_methods.to_a.sort.join(", ")}"
159
+ output.puts " superclass: #{constant.superclass || '(none)'}"
160
+ output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(', ')}"
161
+ output.puts " class methods: #{constant.class_methods.to_a.sort.join(', ')}"
159
162
  end
160
163
  end
161
164
  end
162
165
 
163
166
  def output_component_reasons(output, assignments)
164
167
  if assignments.empty?
165
- output.puts " components: (none)"
168
+ output.puts ' components: (none)'
166
169
  return
167
170
  end
168
171
 
169
- output.puts " components:"
172
+ output.puts ' components:'
170
173
  assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
171
- output.puts " #{name}: #{reasons.empty? ? "(no recorded reason)" : reasons.join("; ")}"
174
+ output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
172
175
  end
173
176
  end
174
177
 
175
178
  def output_suppressions(output, file)
176
179
  return if file.suppressions.empty?
177
180
 
178
- output.puts " suppressions:"
181
+ output.puts ' suppressions:'
179
182
  file.suppressions.each do |suppression|
180
183
  line_range =
181
184
  if suppression.end_line == Float::INFINITY
@@ -185,8 +188,8 @@ module ArchSpec
185
188
  else
186
189
  "#{suppression.start_line}-#{suppression.end_line}"
187
190
  end
188
- rule = suppression.rule || "*"
189
- reason = suppression.reason ? " -- #{suppression.reason}" : ""
191
+ rule = suppression.rule || '*'
192
+ reason = suppression.reason ? " -- #{suppression.reason}" : ''
190
193
  output.puts " #{rule} on line #{line_range}#{reason}"
191
194
  end
192
195
  end
@@ -194,7 +197,7 @@ module ArchSpec
194
197
  def output_parse_errors(output, file)
195
198
  return if file.parse_errors.empty?
196
199
 
197
- output.puts " parse errors:"
200
+ output.puts ' parse errors:'
198
201
  file.parse_errors.each do |parse_error|
199
202
  output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
200
203
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  class ComponentSpec
3
5
  attr_reader :name, :file_patterns, :namespaces, :constants
@@ -28,7 +30,7 @@ module ArchSpec
28
30
  private
29
31
 
30
32
  def normalize_constant(value)
31
- value.to_s.sub(/\A::/, "")
33
+ value.to_s.sub(/\A::/, '')
32
34
  end
33
35
  end
34
36
  end
@@ -1,18 +1,20 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  class Definition
3
5
  DEFAULT_SOURCE_PATTERNS = [
4
- "app/**/*.rb",
5
- "lib/**/*.rb",
6
- "packs/*/app/**/*.rb",
7
- "engines/*/app/**/*.rb"
6
+ 'app/**/*.rb',
7
+ 'lib/**/*.rb',
8
+ 'packs/*/app/**/*.rb',
9
+ 'engines/*/app/**/*.rb'
8
10
  ].freeze
9
11
 
10
12
  DEFAULT_IGNORE_PATTERNS = [
11
- ".git/**/*",
12
- ".bundle/**/*",
13
- "node_modules/**/*",
14
- "tmp/**/*",
15
- "vendor/**/*"
13
+ '.git/**/*',
14
+ '.bundle/**/*',
15
+ 'node_modules/**/*',
16
+ 'tmp/**/*',
17
+ 'vendor/**/*'
16
18
  ].freeze
17
19
 
18
20
  attr_accessor :name, :root_path, :baseline_path
@@ -20,7 +22,7 @@ module ArchSpec
20
22
 
21
23
  def initialize(name)
22
24
  @name = name
23
- @root_path = "."
25
+ @root_path = '.'
24
26
  @baseline_path = nil
25
27
  @source_patterns = []
26
28
  @ignore_patterns = DEFAULT_IGNORE_PATTERNS.dup
@@ -1,4 +1,6 @@
1
- require "digest"
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
2
4
 
3
5
  module ArchSpec
4
6
  class Diagnostic
data/lib/archspec/dsl.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  module DSL
3
5
  module Context
@@ -15,7 +17,7 @@ module ArchSpec
15
17
  add_ignore_patterns(patterns)
16
18
  end
17
19
 
18
- def baseline(path = ".archspec_todo.yml")
20
+ def baseline(path = '.archspec_todo.yml')
19
21
  self.baseline_path = path.to_s
20
22
  end
21
23
 
@@ -101,6 +103,11 @@ module ArchSpec
101
103
  self
102
104
  end
103
105
 
106
+ def must_be_empty(because: nil)
107
+ add_rule(Rules::MustBeEmptyRule.new(name, because: because))
108
+ self
109
+ end
110
+
104
111
  def must_implement(*methods)
105
112
  methods.each do |method_name|
106
113
  add_rule(Rules::MustImplementRule.new(name, method_name))
@@ -121,7 +128,7 @@ module ArchSpec
121
128
  candidate.respond_to?(:merge_key) && candidate.merge_key == rule.merge_key
122
129
  end
123
130
 
124
- return existing.merge!(rule) if existing&.respond_to?(:merge!)
131
+ return existing.merge!(rule) if existing.respond_to?(:merge!)
125
132
  return existing if existing
126
133
  end
127
134
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  module Evaluator
3
5
  extend self
@@ -6,7 +8,10 @@ module ArchSpec
6
8
  (parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) })
7
9
  .reject { |diagnostic| graph.suppressed?(diagnostic) }
8
10
  .reject { |diagnostic| baseline.include?(diagnostic) }
9
- .sort_by { |diagnostic| [diagnostic.location.path, diagnostic.location.line, diagnostic.rule, diagnostic.message] }
11
+ .sort_by do |diagnostic|
12
+ [diagnostic.location.path, diagnostic.location.line, diagnostic.rule,
13
+ diagnostic.message]
14
+ end
10
15
  end
11
16
 
12
17
  private
@@ -15,7 +20,7 @@ module ArchSpec
15
20
  graph.files.values.flat_map do |file|
16
21
  file.parse_errors.map do |parse_error|
17
22
  Diagnostic.new(
18
- rule: "parser.syntax",
23
+ rule: 'parser.syntax',
19
24
  message: parse_error.message,
20
25
  location: parse_error.location,
21
26
  evidence: file.relative_path,
@@ -1,9 +1,11 @@
1
- require "json"
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
2
4
 
3
5
  module ArchSpec
4
6
  module Formatters
5
7
  module JSON
6
- extend self
8
+ module_function
7
9
 
8
10
  def print(output = $stdout, graph:, diagnostics:)
9
11
  output.puts ::JSON.pretty_generate(
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  module Formatters
3
5
  module Text
4
- extend self
6
+ module_function
5
7
 
6
8
  def print(output = $stdout, graph:, diagnostics:)
7
9
  if diagnostics.empty?
@@ -9,7 +11,7 @@ module ArchSpec
9
11
  return
10
12
  end
11
13
 
12
- output.puts "#{diagnostics.size} architecture #{diagnostics.size == 1 ? "violation" : "violations"}"
14
+ output.puts "#{diagnostics.size} architecture #{diagnostics.size == 1 ? 'violation' : 'violations'}"
13
15
  output.puts
14
16
 
15
17
  diagnostics.each do |diagnostic|
@@ -1,5 +1,6 @@
1
- require "pathname"
2
- require "set"
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
3
4
 
4
5
  module ArchSpec
5
6
  ParseError = Data.define(:message, :location)
@@ -167,7 +168,8 @@ module ArchSpec
167
168
  next unless matched_file || matched_constant
168
169
 
169
170
  component.add_file(constant.path, reason: "defines #{constant.name}") if matched_constant
170
- component.add_constant(constant.name, reason: matched_file ? "defined in matched file" : "matched namespace/constant selector")
171
+ component.add_constant(constant.name,
172
+ reason: matched_file ? 'defined in matched file' : 'matched namespace/constant selector')
171
173
  end
172
174
 
173
175
  @components[component.name] = component
@@ -207,11 +209,11 @@ module ArchSpec
207
209
  candidates = []
208
210
 
209
211
  if from_constant
210
- namespace = normalize_constant(from_constant).split("::")
212
+ namespace = normalize_constant(from_constant).split('::')
211
213
  namespace.pop
212
214
 
213
215
  until namespace.empty?
214
- candidates << "#{namespace.join("::")}::#{normalized}"
216
+ candidates << "#{namespace.join('::')}::#{normalized}"
215
217
  namespace.pop
216
218
  end
217
219
  end
@@ -272,7 +274,7 @@ module ArchSpec
272
274
  end
273
275
 
274
276
  def normalize_constant(value)
275
- value.to_s.sub(/\A::/, "")
277
+ value.to_s.sub(/\A::/, '')
276
278
  end
277
279
  end
278
280
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  module Presets
3
5
  module_function
@@ -8,6 +10,8 @@ module ArchSpec
8
10
  rails_way(dsl, **options)
9
11
  when :rails_strict
10
12
  rails_strict(dsl, **options)
13
+ when :vanilla_rails
14
+ vanilla_rails(dsl, **options)
11
15
  when :rails_layered
12
16
  rails_layered(dsl, **options)
13
17
  when :rails_hexagonal
@@ -33,6 +37,23 @@ module ArchSpec
33
37
  dsl.no_cycles!(among: %i[controllers models helpers mailers jobs services])
34
38
  end
35
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
+
36
57
  def rails_layered(dsl, **options)
37
58
  Architectures.apply(:layered, dsl, **options)
38
59
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Rules
5
+ class MustBeEmptyRule
6
+ attr_reader :source, :because
7
+
8
+ def initialize(source, because: nil)
9
+ @source = source.to_sym
10
+ @because = because
11
+ end
12
+
13
+ def merge_key
14
+ [self.class, source]
15
+ end
16
+
17
+ def id
18
+ 'components.empty'
19
+ end
20
+
21
+ def evaluate(graph)
22
+ component = graph.components[source]
23
+ return [] unless component
24
+
25
+ component.files.sort.map do |path|
26
+ relative = graph.files[path]&.relative_path || path
27
+
28
+ Diagnostic.new(
29
+ rule: id,
30
+ message: because ? "#{source} must stay empty: #{because}" : "#{source} must stay empty",
31
+ location: SourceLocation.new(path, 1, 1),
32
+ evidence: "#{relative} belongs to #{source}"
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  module Rules
3
5
  class NoCyclesRule
@@ -8,7 +10,7 @@ module ArchSpec
8
10
  end
9
11
 
10
12
  def id
11
- "dependencies.no_cycles"
13
+ 'dependencies.no_cycles'
12
14
  end
13
15
 
14
16
  def evaluate(graph)
@@ -16,9 +18,9 @@ module ArchSpec
16
18
  location = first_location_for_cycle(graph, cycle) || SourceLocation.new(graph.root, 1, 1)
17
19
  Diagnostic.new(
18
20
  rule: id,
19
- message: "component dependency cycle: #{cycle.join(" -> ")}",
21
+ message: "component dependency cycle: #{cycle.join(' -> ')}",
20
22
  location: location,
21
- evidence: cycle.join(" -> ")
23
+ evidence: cycle.join(' -> ')
22
24
  )
23
25
  end
24
26
  end
@@ -26,17 +28,17 @@ module ArchSpec
26
28
  private
27
29
 
28
30
  def cycles(graph)
29
- adjacency = Hash.new { |hash, key| hash[key] = Set.new }
31
+ adjacency = {}
30
32
  graph.component_dependency_pairs(only: components).each do |source, target|
31
33
  next if source == target
32
34
 
33
- adjacency[source].add(target)
35
+ (adjacency[source] ||= Set.new).add(target)
34
36
  end
35
37
 
36
38
  found = Set.new
37
39
  result = []
38
40
 
39
- adjacency.keys.each do |start|
41
+ adjacency.each_key do |start|
40
42
  walk(adjacency, start, start, [], found, result)
41
43
  end
42
44
 
@@ -46,7 +48,7 @@ module ArchSpec
46
48
  def walk(adjacency, start, node, path, found, result)
47
49
  next_path = path + [node]
48
50
 
49
- adjacency[node].each do |target|
51
+ adjacency.fetch(node, []).each do |target|
50
52
  if target == start
51
53
  cycle = canonical_cycle(next_path + [start])
52
54
  key = cycle.join("\0")
@@ -1,4 +1,4 @@
1
- require "set"
1
+ # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
4
  module Rules
@@ -36,7 +36,7 @@ module ArchSpec
36
36
 
37
37
  class AllowDependenciesRule < DependencyRule
38
38
  def id
39
- "dependencies.allow"
39
+ 'dependencies.allow'
40
40
  end
41
41
 
42
42
  def evaluate(graph)
@@ -57,7 +57,7 @@ module ArchSpec
57
57
 
58
58
  class ForbidDependenciesRule < DependencyRule
59
59
  def id
60
- "dependencies.forbid"
60
+ 'dependencies.forbid'
61
61
  end
62
62
 
63
63
  def evaluate(graph)
@@ -81,7 +81,7 @@ module ArchSpec
81
81
 
82
82
  def initialize(source, constants)
83
83
  @source = source.to_sym
84
- @constants = Array(constants).flatten.map { |constant| constant.to_s.sub(/\A::/, "") }
84
+ @constants = Array(constants).flatten.map { |constant| constant.to_s.sub(/\A::/, '') }
85
85
  end
86
86
 
87
87
  def merge_key
@@ -94,7 +94,7 @@ module ArchSpec
94
94
  end
95
95
 
96
96
  def id
97
- "constants.forbid"
97
+ 'constants.forbid'
98
98
  end
99
99
 
100
100
  def evaluate(graph)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  module Rules
3
5
  class CannotCallRule
@@ -18,7 +20,7 @@ module ArchSpec
18
20
  end
19
21
 
20
22
  def id
21
- "methods.forbid"
23
+ 'methods.forbid'
22
24
  end
23
25
 
24
26
  def evaluate(graph)
@@ -50,7 +52,7 @@ module ArchSpec
50
52
  end
51
53
 
52
54
  def id
53
- "protocol.must_implement"
55
+ 'protocol.must_implement'
54
56
  end
55
57
 
56
58
  def evaluate(graph)
@@ -61,7 +63,7 @@ module ArchSpec
61
63
  rule: id,
62
64
  message: "#{constant.name} must implement ##{method_name}",
63
65
  location: constant.location,
64
- evidence: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(", ")}"
66
+ evidence: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(', ')}"
65
67
  )
66
68
  end
67
69
  end
@@ -93,7 +95,7 @@ module ArchSpec
93
95
  end
94
96
 
95
97
  def id
96
- "protocol.must_implement_one_of"
98
+ 'protocol.must_implement_one_of'
97
99
  end
98
100
 
99
101
  def evaluate(graph)
@@ -102,9 +104,9 @@ module ArchSpec
102
104
 
103
105
  Diagnostic.new(
104
106
  rule: id,
105
- message: "#{constant.name} must implement one of #{method_names.map { |name| "##{name}" }.join(", ")}",
107
+ message: "#{constant.name} must implement one of #{method_names.map { |name| "##{name}" }.join(', ')}",
106
108
  location: constant.location,
107
- evidence: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(", ")}"
109
+ evidence: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(', ')}"
108
110
  )
109
111
  end
110
112
  end
@@ -136,7 +138,7 @@ module ArchSpec
136
138
  end
137
139
 
138
140
  def id
139
- "methods.define_forbid"
141
+ 'methods.define_forbid'
140
142
  end
141
143
 
142
144
  def evaluate(graph)
@@ -165,7 +167,7 @@ module ArchSpec
165
167
  end
166
168
 
167
169
  def id
168
- "objects.instantiate_and_invoke_forbid"
170
+ 'objects.instantiate_and_invoke_forbid'
169
171
  end
170
172
 
171
173
  def evaluate(graph)
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
4
  module Rules
3
5
  class ZeitwerkNamingRule
4
6
  def id
5
- "zeitwerk.naming"
7
+ 'zeitwerk.naming'
6
8
  end
7
9
 
8
10
  def evaluate(graph)
@@ -16,7 +18,7 @@ module ArchSpec
16
18
  rule: id,
17
19
  message: "#{file.relative_path} should define #{file.expected_constant}",
18
20
  location: SourceLocation.new(file.path, 1, 1),
19
- evidence: "defined constants: #{defined.empty? ? "(none)" : defined.join(", ")}"
21
+ evidence: "defined constants: #{defined.empty? ? '(none)' : defined.join(', ')}"
20
22
  )
21
23
  end
22
24
  end
@@ -1,4 +1,6 @@
1
- require "pathname"
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
2
4
 
3
5
  module ArchSpec
4
6
  SourceLocation = Data.define(:path, :line, :column) do
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ArchSpec
2
- VERSION = "0.1.0.pre1"
4
+ VERSION = '0.1.0.pre2'
3
5
  end
data/lib/archspec.rb CHANGED
@@ -1,22 +1,25 @@
1
- require_relative "archspec/version"
2
- require_relative "archspec/source_location"
3
- require_relative "archspec/diagnostic"
4
- require_relative "archspec/component_spec"
5
- require_relative "archspec/model"
6
- require_relative "archspec/definition"
7
- require_relative "archspec/baseline"
8
- require_relative "archspec/dsl"
9
- require_relative "archspec/analyzer"
10
- require_relative "archspec/evaluator"
11
- require_relative "archspec/architectures"
12
- require_relative "archspec/presets"
13
- require_relative "archspec/rules/dependency_rules"
14
- require_relative "archspec/rules/protocol_rules"
15
- require_relative "archspec/rules/cycle_rule"
16
- require_relative "archspec/rules/zeitwerk_rule"
17
- require_relative "archspec/formatters/text"
18
- require_relative "archspec/formatters/json"
19
- require_relative "archspec/cli"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'archspec/version'
4
+ require_relative 'archspec/source_location'
5
+ require_relative 'archspec/diagnostic'
6
+ require_relative 'archspec/component_spec'
7
+ require_relative 'archspec/model'
8
+ require_relative 'archspec/definition'
9
+ require_relative 'archspec/baseline'
10
+ require_relative 'archspec/dsl'
11
+ require_relative 'archspec/analyzer'
12
+ require_relative 'archspec/evaluator'
13
+ require_relative 'archspec/architectures'
14
+ require_relative 'archspec/presets'
15
+ require_relative 'archspec/rules/component_rules'
16
+ require_relative 'archspec/rules/dependency_rules'
17
+ require_relative 'archspec/rules/protocol_rules'
18
+ require_relative 'archspec/rules/cycle_rule'
19
+ require_relative 'archspec/rules/zeitwerk_rule'
20
+ require_relative 'archspec/formatters/text'
21
+ require_relative 'archspec/formatters/json'
22
+ require_relative 'archspec/cli'
20
23
 
21
24
  module ArchSpec
22
25
  class Error < StandardError; end
@@ -24,7 +27,7 @@ module ArchSpec
24
27
  class << self
25
28
  attr_accessor :last_definition
26
29
 
27
- def define(name = "Architecture", &block)
30
+ def define(name = 'Architecture', &block)
28
31
  definition = Definition.new(name)
29
32
  definition.extend(DSL::Context)
30
33
  definition.instance_eval(&block) if block
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.1.0.pre1
4
+ version: 0.1.0.pre2
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-06-02 00:00:00.000000000 Z
11
+ date: 2026-06-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -77,6 +77,7 @@ files:
77
77
  - lib/archspec/formatters/text.rb
78
78
  - lib/archspec/model.rb
79
79
  - lib/archspec/presets.rb
80
+ - lib/archspec/rules/component_rules.rb
80
81
  - lib/archspec/rules/cycle_rule.rb
81
82
  - lib/archspec/rules/dependency_rules.rb
82
83
  - lib/archspec/rules/protocol_rules.rb