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.
@@ -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
- next if constant.instance_methods.include?(method_name)
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: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(', ')}"
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
- graph.components.fetch(source).constants.flat_map { |name| graph.constants_named(name) }.select(&:class?)
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
- next if method_names.any? { |method_name| constant.instance_methods.include?(method_name) }
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: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(', ')}"
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
- graph.components.fetch(source).constants.flat_map { |name| graph.constants_named(name) }.select(&:class?)
118
- rescue KeyError
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
- class Baseline
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
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/baseline'
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.3.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-06-21 00:00:00.000000000 Z
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