archspec 1.0.1 → 1.1.0.rc2

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.
@@ -74,31 +74,22 @@ module ArchSpec
74
74
 
75
75
  # Forbids any selected method from existing. Rule id +naming.forbidden+.
76
76
  class Forbidden
77
- def initialize(because: nil)
78
- @because = because
79
- end
80
-
81
77
  def id
82
78
  'naming.forbidden'
83
79
  end
84
80
 
85
81
  def diagnostics(selected, rule, _graph)
86
82
  selected.map do |definition, _match|
83
+ evidence = "#{definition.owner} defines #{definition.scope} method #{definition.name} " \
84
+ "(#{rule.selector.describe})"
87
85
  Diagnostic.new(
88
86
  rule: id,
89
- message: message_for(definition),
87
+ message: "#{definition.owner} must not define ##{definition.name}",
90
88
  location: definition.location,
91
- evidence: "#{definition.owner} defines #{definition.scope} method #{definition.name} (#{rule.selector.describe})"
89
+ evidence: evidence
92
90
  )
93
91
  end
94
92
  end
95
-
96
- private
97
-
98
- def message_for(definition)
99
- base = "#{definition.owner} must not define ##{definition.name}"
100
- @because ? "#{base}: #{@because}" : base
101
- end
102
93
  end
103
94
 
104
95
  # Requires each selected method to have a sibling named by a template, in
@@ -106,7 +97,7 @@ module ArchSpec
106
97
  # interpolates the selector's named captures, as in
107
98
  # <tt>requires("without_%{base}")</tt>. Rule id +naming.requires+.
108
99
  class Requires
109
- def initialize(template, on: nil, scope: :instance, because: nil)
100
+ def initialize(template, on: nil, scope: :instance)
110
101
  unless NamingRule::VALID_SCOPES.include?(scope)
111
102
  raise Error, "requires scope: must be :instance or :class, got #{scope.inspect}"
112
103
  end
@@ -115,7 +106,6 @@ module ArchSpec
115
106
  @template = template
116
107
  @on = on
117
108
  @target_scope = scope
118
- @because = because
119
109
  end
120
110
 
121
111
  def id
@@ -163,8 +153,7 @@ module ArchSpec
163
153
  else
164
154
  "a matching ##{sibling}"
165
155
  end
166
- base = "#{definition.owner}##{definition.name} requires #{clause}"
167
- @because ? "#{base}: #{@because}" : base
156
+ "#{definition.owner}##{definition.name} requires #{clause}"
168
157
  end
169
158
  end
170
159
 
@@ -190,17 +179,17 @@ module ArchSpec
190
179
  end
191
180
 
192
181
  def forbidden(except: [], because: nil)
193
- add(Forbidden.new(because: because), except)
182
+ add(Forbidden.new, except, because)
194
183
  end
195
184
 
196
185
  def requires(template, on: nil, scope: :instance, except: [], because: nil)
197
186
  validate_template!(template)
198
- add(Requires.new(template, on: component_name(on), scope: scope, because: because), except)
187
+ add(Requires.new(template, on: component_name(on), scope: scope), except, because)
199
188
  end
200
189
 
201
190
  private
202
191
 
203
- def add(constraint, except)
192
+ def add(constraint, except, because)
204
193
  rule = NamingRule.new(
205
194
  source: @component.name,
206
195
  selector: @selector,
@@ -208,7 +197,7 @@ module ArchSpec
208
197
  scope: @scope,
209
198
  except: except
210
199
  )
211
- @component.definition.add_rule(rule)
200
+ @component.definition.add_rule(Rules.with_reason(rule, because))
212
201
  @component
213
202
  end
214
203
 
@@ -224,7 +213,7 @@ module ArchSpec
224
213
  end
225
214
 
226
215
  def validate_template!(template)
227
- return unless template.is_a?(String)
216
+ return unless template.is_a?(String) && template.include?('%')
228
217
 
229
218
  captures = @selector.regex.names.to_h { |name| [name.to_sym, name] }
230
219
  template % captures
@@ -8,13 +8,13 @@ module ArchSpec
8
8
  attr_reader :source, :method_names, :receiver
9
9
 
10
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}"
11
+ unless %i[any none].include?(receiver) || receiver.is_a?(String)
12
+ raise Error, "cannot_call receiver: must be :any, :none or a constant name, got #{receiver.inspect}"
13
13
  end
14
14
 
15
15
  @source = source.to_sym
16
16
  @method_names = Array(methods).flatten.map(&:to_sym)
17
- @receiver = receiver
17
+ @receiver = receiver.is_a?(String) ? receiver.sub(/\A::/, '') : receiver
18
18
  end
19
19
 
20
20
  def merge_key
@@ -33,22 +33,42 @@ module ArchSpec
33
33
  def evaluate(graph)
34
34
  graph.edges.filter_map do |edge|
35
35
  next unless edge.type == :calls_named_method
36
- next unless method_names.include?(edge.to.to_sym)
36
+ forbidden = forbidden_name(edge)
37
+ next unless forbidden
37
38
  next if receiver == :none && edge.receiver != :none
39
+ next if receiver.is_a?(String) && !receiver_matches?(graph, edge)
38
40
  next unless graph.source_components_for(edge).include?(source)
39
41
  next if own_method_call?(graph, edge)
40
42
 
41
43
  Diagnostic.new(
42
44
  rule: id,
43
- message: "#{source} must not call ##{edge.to}",
45
+ message: "#{source} must not call ##{forbidden}",
44
46
  location: edge.location,
45
- evidence: "#{graph.edge_source_name(edge)} calls #{edge.to}"
47
+ evidence: evidence_for(graph, edge)
46
48
  )
47
49
  end
48
50
  end
49
51
 
50
52
  private
51
53
 
54
+ def forbidden_name(edge)
55
+ return edge.to.to_sym if method_names.include?(edge.to.to_sym)
56
+ return edge.resolved_method if edge.resolved_method && method_names.include?(edge.resolved_method)
57
+ end
58
+
59
+ def receiver_matches?(graph, edge)
60
+ return false unless edge.resolved_receiver
61
+ return true if edge.resolved_receiver == receiver
62
+
63
+ graph.ancestor_names(edge.resolved_receiver, scope: edge.receiver_scope || :instance).first.include?(receiver)
64
+ end
65
+
66
+ def evidence_for(graph, edge)
67
+ evidence = "#{graph.edge_source_name(edge)} calls #{edge.to}"
68
+ evidence = "#{evidence} (alias of #{edge.resolved_method})" if edge.resolved_method
69
+ receiver.is_a?(String) ? "#{evidence} on #{edge.resolved_receiver}" : evidence
70
+ end
71
+
52
72
  # A receiverless call to a method the class itself defines (directly,
53
73
  # inherited, or via attr_*/attribute/delegate) is a call to its own API.
54
74
  def own_method_call?(graph, edge)
@@ -63,15 +83,23 @@ module ArchSpec
63
83
  # component that do not implement the method, counting inherited and
64
84
  # mixed-in methods.
65
85
  class MustImplementRule
66
- attr_reader :source, :method_name
86
+ attr_reader :source, :method_name, :scope, :arity, :keywords
87
+
88
+ def initialize(source, method_name, scope: :instance, arity: nil, keywords: nil)
89
+ ProtocolEvidence.validate_scope!(scope, for_rule: 'must_implement')
90
+ if arity && (!arity.is_a?(Integer) || arity.negative?)
91
+ raise Error, "must_implement arity: must be a non-negative Integer, got #{arity.inspect}"
92
+ end
67
93
 
68
- def initialize(source, method_name)
69
94
  @source = source.to_sym
70
95
  @method_name = method_name.to_sym
96
+ @scope = scope
97
+ @arity = arity
98
+ @keywords = Array(keywords).compact.map(&:to_sym).to_set
71
99
  end
72
100
 
73
101
  def merge_key
74
- [self.class, source, method_name]
102
+ [self.class, source, method_name, scope, arity, keywords]
75
103
  end
76
104
 
77
105
  def id
@@ -80,15 +108,16 @@ module ArchSpec
80
108
 
81
109
  def evaluate(graph)
82
110
  constants_for(graph).filter_map do |constant|
83
- methods, unresolved = graph.effective_instance_methods(constant.name)
84
- next if methods.include?(method_name)
111
+ definitions, unresolved = graph.effective_method_definitions(constant.name, scope)
112
+ implementations = definitions.select { |definition| definition.name == method_name }
113
+ next if requirement_met?(implementations)
85
114
 
86
115
  Diagnostic.new(
87
116
  rule: id,
88
- message: "#{constant.name} must implement ##{method_name}",
117
+ message: "#{constant.name} must implement #{method_label}#{requirement_clause}",
89
118
  location: constant.location,
90
- evidence: ProtocolEvidence.for(constant, methods, unresolved),
91
- confidence: unresolved.empty? ? :high : :medium
119
+ evidence: evidence_for(constant, definitions, implementations, unresolved),
120
+ confidence: unresolved.empty? && signature_known?(implementations) ? :high : :medium
92
121
  )
93
122
  end
94
123
  end
@@ -98,23 +127,73 @@ module ArchSpec
98
127
  def constants_for(graph)
99
128
  ProtocolEvidence.constants_for(graph, source)
100
129
  end
130
+
131
+ def requirement_met?(implementations)
132
+ return false if implementations.empty?
133
+ return true unless signature_required?
134
+
135
+ implementations.any? do |definition|
136
+ definition.signatures.any? do |signature|
137
+ (arity.nil? || signature.accepts_arity?(arity)) && signature.accepts_keywords?(keywords)
138
+ end
139
+ end
140
+ end
141
+
142
+ def signature_required?
143
+ !arity.nil? || keywords.any?
144
+ end
145
+
146
+ def signature_known?(implementations)
147
+ !signature_required? || implementations.any? { |definition| definition.signatures.any? }
148
+ end
149
+
150
+ def method_label
151
+ "#{scope == :class ? '.' : '#'}#{method_name}"
152
+ end
153
+
154
+ def requirement_clause
155
+ requirements = []
156
+ requirements << "#{arity} positional #{arity == 1 ? 'argument' : 'arguments'}" if arity
157
+ requirements << "keywords #{keywords.map { |name| "#{name}:" }.sort.join(', ')}" if keywords.any?
158
+ requirements.empty? ? '' : " accepting #{requirements.join(' and ')}"
159
+ end
160
+
161
+ def evidence_for(constant, definitions, implementations, unresolved)
162
+ if implementations.empty?
163
+ return ProtocolEvidence.for(constant, definitions.map(&:name).to_set, unresolved, scope: scope)
164
+ end
165
+
166
+ signatures = implementations.flat_map(&:signatures)
167
+ detail =
168
+ if signatures.empty?
169
+ 'has no recorded signature'
170
+ else
171
+ "accepts #{signatures.map(&:describe).uniq.join(' or ')}"
172
+ end
173
+ evidence = "#{constant.name} #{method_label} #{detail}"
174
+ return evidence if unresolved.empty?
175
+
176
+ "#{evidence}; unresolved ancestors: #{unresolved.to_a.sort.join(', ')}"
177
+ end
101
178
  end
102
179
 
103
180
  # Backs ArchSpec::DSL::ComponentProxy#must_implement_one_of. Flags classes
104
181
  # that implement none of the named methods.
105
182
  class MustImplementOneOfRule
106
- attr_reader :source, :method_names
183
+ attr_reader :source, :method_names, :scope
107
184
 
108
- def initialize(source, method_names)
185
+ def initialize(source, method_names, scope: :instance)
186
+ ProtocolEvidence.validate_scope!(scope, for_rule: 'must_implement_one_of')
109
187
  @source = source.to_sym
110
188
  names = Array(method_names).flatten.compact
111
189
  raise Error, 'must_implement_one_of requires at least one method' if names.empty?
112
190
 
113
191
  @method_names = names.map(&:to_sym)
192
+ @scope = scope
114
193
  end
115
194
 
116
195
  def merge_key
117
- [self.class, source]
196
+ [self.class, source, scope]
118
197
  end
119
198
 
120
199
  def merge!(other)
@@ -128,14 +207,21 @@ module ArchSpec
128
207
 
129
208
  def evaluate(graph)
130
209
  constants_for(graph).filter_map do |constant|
131
- methods, unresolved = graph.effective_instance_methods(constant.name)
210
+ methods, unresolved =
211
+ if scope == :class
212
+ graph.effective_class_methods(constant.name)
213
+ else
214
+ graph.effective_instance_methods(constant.name)
215
+ end
132
216
  next if method_names.any? { |method_name| methods.include?(method_name) }
133
217
 
218
+ sigil = scope == :class ? '.' : '#'
219
+ choices = method_names.map { |name| "#{sigil}#{name}" }.join(', ')
134
220
  Diagnostic.new(
135
221
  rule: id,
136
- message: "#{constant.name} must implement one of #{method_names.map { |name| "##{name}" }.join(', ')}",
222
+ message: "#{constant.name} must implement one of #{choices}",
137
223
  location: constant.location,
138
- evidence: ProtocolEvidence.for(constant, methods, unresolved),
224
+ evidence: ProtocolEvidence.for(constant, methods, unresolved, scope: scope),
139
225
  confidence: unresolved.empty? ? :high : :medium
140
226
  )
141
227
  end
@@ -153,6 +239,14 @@ module ArchSpec
153
239
  module ProtocolEvidence
154
240
  module_function
155
241
 
242
+ VALID_SCOPES = %i[instance class].freeze
243
+
244
+ def validate_scope!(scope, for_rule:)
245
+ return if VALID_SCOPES.include?(scope)
246
+
247
+ raise Error, "#{for_rule} scope: must be :instance or :class, got #{scope.inspect}"
248
+ end
249
+
156
250
  def constants_for(graph, source)
157
251
  component = graph.components[source]
158
252
  return [] unless component
@@ -160,8 +254,9 @@ module ArchSpec
160
254
  graph.constants_for_component(source).select(&:class?).uniq(&:name)
161
255
  end
162
256
 
163
- def for(constant, methods, unresolved)
164
- evidence = "#{constant.name} methods: #{methods.empty? ? '(none)' : methods.to_a.sort.join(', ')}"
257
+ def for(constant, methods, unresolved, scope: :instance)
258
+ label = scope == :class ? 'class methods' : 'methods'
259
+ evidence = "#{constant.name} #{label}: #{methods.empty? ? '(none)' : methods.to_a.sort.join(', ')}"
165
260
  return evidence if unresolved.empty?
166
261
 
167
262
  "#{evidence}; unresolved ancestors: #{unresolved.to_a.sort.join(', ')}"
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Rules
5
+ # Attaches a human reason to any rule without coupling that metadata to the
6
+ # rule's implementation or to diagnostic fingerprints.
7
+ module Reasoned
8
+ attr_reader :archspec_because
9
+
10
+ def archspec_because=(reason)
11
+ return unless reason
12
+
13
+ reason = reason.to_s.strip
14
+ raise Error, 'because: must not be empty' if reason.empty?
15
+ if archspec_because && archspec_because != reason
16
+ raise Error, "the same rule cannot have two reasons: #{archspec_because.inspect} and #{reason.inspect}"
17
+ end
18
+
19
+ @archspec_because = reason
20
+ end
21
+
22
+ def evaluate(graph)
23
+ super.map { |diagnostic| diagnostic.with_reason(archspec_because) }
24
+ end
25
+ end
26
+
27
+ module_function
28
+
29
+ def with_reason(rule, because)
30
+ return rule unless because
31
+
32
+ rule.extend(Reasoned) unless rule.is_a?(Reasoned)
33
+ rule.archspec_because = because
34
+ rule
35
+ end
36
+ end
37
+ end
@@ -2,10 +2,8 @@
2
2
 
3
3
  require 'pathname'
4
4
 
5
- require_relative 'value_object'
6
-
7
5
  module ArchSpec
8
- SourceLocation = ValueObject.define(:path, :line, :column, :end_line, :end_column) do
6
+ SourceLocation = Data.define(:path, :line, :column, :end_line, :end_column) do
9
7
  def self.from_prism(path, location)
10
8
  new(path, location.start_line, location.start_column + 1, location.end_line, location.end_column + 1)
11
9
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
- VERSION = '1.0.1'
4
+ VERSION = '1.1.0.rc2'
5
5
  end
data/lib/archspec.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative 'archspec/error'
4
4
  require_relative 'archspec/version'
5
- require_relative 'archspec/value_object'
6
5
  require_relative 'archspec/source_location'
7
6
  require_relative 'archspec/diagnostic'
8
7
  require_relative 'archspec/component_spec'
@@ -10,9 +9,11 @@ require_relative 'archspec/model'
10
9
  require_relative 'archspec/definition'
11
10
  require_relative 'archspec/todo'
12
11
  require_relative 'archspec/dsl'
12
+ require_relative 'archspec/rubydex_index'
13
13
  require_relative 'archspec/analyzer'
14
14
  require_relative 'archspec/evaluator'
15
15
  require_relative 'archspec/architectures'
16
+ require_relative 'archspec/rules/reasoned'
16
17
  require_relative 'archspec/rules/component_rules'
17
18
  require_relative 'archspec/rules/concern_rules'
18
19
  require_relative 'archspec/rules/dependency_rules'
@@ -30,7 +31,8 @@ require_relative 'archspec/cli'
30
31
  #
31
32
  # You describe components, dependencies, and boundaries in an +Archspec.rb+
32
33
  # file written in the ArchSpec::DSL, then run <tt>archspec check</tt> to verify
33
- # every change. ArchSpec reads Ruby source with Prism and never boots the app.
34
+ # every change. ArchSpec indexes Ruby source with Rubydex, uses Prism for a
35
+ # handful of syntax-specific facts, and never boots the app.
34
36
  #
35
37
  # The DSL is the public API. An +Archspec.rb+ file is evaluated directly:
36
38
  #
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: 1.0.1
4
+ version: 1.1.0.rc2
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-08-20 00:00:00.000000000 Z
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rubydex
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.4.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.4.0
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: minitest
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -73,8 +87,8 @@ dependencies:
73
87
  - !ruby/object:Gem::Version
74
88
  version: '6.6'
75
89
  description: A static architecture linter for Ruby and Rails. Declare your components,
76
- dependencies, and boundaries in one file, then check every change in CI. It reads
77
- source with Prism and never boots the app.
90
+ dependencies, and boundaries in one file, then check every change in CI. It indexes
91
+ source with Rubydex and never boots the app.
78
92
  email:
79
93
  - carmine@paolino.me
80
94
  executables:
@@ -100,6 +114,7 @@ files:
100
114
  - lib/archspec/formatters/style.rb
101
115
  - lib/archspec/formatters/text.rb
102
116
  - lib/archspec/model.rb
117
+ - lib/archspec/rubydex_index.rb
103
118
  - lib/archspec/rules/component_rules.rb
104
119
  - lib/archspec/rules/concern_rules.rb
105
120
  - lib/archspec/rules/cycle_rule.rb
@@ -107,9 +122,9 @@ files:
107
122
  - lib/archspec/rules/naming_rules.rb
108
123
  - lib/archspec/rules/privacy_rule.rb
109
124
  - lib/archspec/rules/protocol_rules.rb
125
+ - lib/archspec/rules/reasoned.rb
110
126
  - lib/archspec/source_location.rb
111
127
  - lib/archspec/todo.rb
112
- - lib/archspec/value_object.rb
113
128
  - lib/archspec/version.rb
114
129
  homepage: https://archspecrb.dev
115
130
  licenses:
@@ -129,7 +144,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
129
144
  requirements:
130
145
  - - ">="
131
146
  - !ruby/object:Gem::Version
132
- version: 3.1.3
147
+ version: '3.2'
133
148
  required_rubygems_version: !ruby/object:Gem::Requirement
134
149
  requirements:
135
150
  - - ">="
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ArchSpec
4
- # A frozen value type with positional or keyword construction and #with,
5
- # mirroring Ruby 3.2's Data.define. Deliberately hand-rolled: the gem
6
- # supports Ruby 3.1, where Data does not exist. Do not replace this with
7
- # Data until the required Ruby version reaches 3.2.
8
- module ValueObject
9
- def self.define(*members, &block)
10
- klass = Struct.new(*members) do
11
- members.each { |member| undef_method :"#{member}=" }
12
-
13
- def initialize(*values, **keywords)
14
- member_names = self.class.members
15
-
16
- if keywords.any?
17
- raise ArgumentError, 'expected positional arguments or keyword arguments, not both' unless values.empty?
18
-
19
- unknown = keywords.keys - member_names
20
- raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" if unknown.any?
21
-
22
- missing = member_names - keywords.keys
23
- raise ArgumentError, "missing keyword: #{missing.first.inspect}" if missing.any?
24
-
25
- values = member_names.map { |member| keywords.fetch(member) }
26
- elsif values.length != member_names.length
27
- raise ArgumentError, "wrong number of arguments (given #{values.length}, expected #{member_names.length})"
28
- end
29
-
30
- super(*values)
31
- freeze
32
- end
33
-
34
- def with(**changes)
35
- unknown = changes.keys - self.class.members
36
- raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" if unknown.any?
37
-
38
- self.class.new(**to_h.merge(changes))
39
- end
40
- end
41
-
42
- klass.class_eval(&block) if block
43
- klass
44
- end
45
- end
46
- end