archspec 0.4.0 → 1.0.0.rc1

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.
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module ArchSpec
6
+ module Rules
7
+ # Backs the +method_names.matching(...)+ DSL. One rule pairs a selector (which
8
+ # methods it judges) with a constraint (what must hold of them). It judges a
9
+ # component's defined, public methods in the requested scope, or every such
10
+ # method in the project when +source+ is nil. Every finding is name-based and
11
+ # exact, so confidence is always +:high+.
12
+ class NamingRule
13
+ VALID_SCOPES = %i[instance class].freeze
14
+
15
+ attr_reader :source, :selector, :scope, :except
16
+
17
+ def initialize(source:, selector:, constraint:, scope: :instance, except: [])
18
+ unless VALID_SCOPES.include?(scope)
19
+ raise Error, "method_names scope: must be :instance or :class, got #{scope.inspect}"
20
+ end
21
+
22
+ @source = source&.to_sym
23
+ @selector = selector
24
+ @constraint = constraint
25
+ @scope = scope
26
+ @except = Array(except).flatten.map(&:to_sym).to_set
27
+ end
28
+
29
+ def id
30
+ @constraint.id
31
+ end
32
+
33
+ def evaluate(graph)
34
+ selected = candidate_methods(graph).filter_map do |definition|
35
+ next if except.include?(definition.name)
36
+
37
+ match = selector.match(definition)
38
+ [definition, match] if match
39
+ end
40
+
41
+ @constraint.diagnostics(selected, self, graph)
42
+ end
43
+
44
+ private
45
+
46
+ def candidate_methods(graph)
47
+ definitions = source ? graph.method_definitions_for_component(source) : graph.method_definitions
48
+ definitions.select { |definition| definition.scope == scope && definition.visibility == :public }
49
+ end
50
+ end
51
+
52
+ # The selectors and constraints the naming DSL composes, plus the builder the
53
+ # DSL returns.
54
+ module Naming
55
+ # Selects methods whose name matches a regex. A named capture in the regex
56
+ # (such as <tt>(?<base>.+)</tt>) is exposed to the +requires+ constraint.
57
+ class NameSelector
58
+ attr_reader :regex
59
+
60
+ def initialize(regex)
61
+ raise Error, "method_names matching expects a Regexp, got #{regex.inspect}" unless regex.is_a?(Regexp)
62
+
63
+ @regex = regex
64
+ end
65
+
66
+ def match(definition)
67
+ regex.match(definition.name.to_s)
68
+ end
69
+
70
+ def describe
71
+ "matches #{regex.inspect}"
72
+ end
73
+ end
74
+
75
+ # Forbids any selected method from existing. Rule id +naming.forbidden+.
76
+ class Forbidden
77
+ def initialize(because: nil)
78
+ @because = because
79
+ end
80
+
81
+ def id
82
+ 'naming.forbidden'
83
+ end
84
+
85
+ def diagnostics(selected, rule, _graph)
86
+ selected.map do |definition, _match|
87
+ Diagnostic.new(
88
+ rule: id,
89
+ message: message_for(definition),
90
+ location: definition.location,
91
+ evidence: "#{definition.owner} defines #{definition.scope} method #{definition.name} (#{rule.selector.describe})"
92
+ )
93
+ end
94
+ 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
+ end
103
+
104
+ # Requires each selected method to have a sibling named by a template, in
105
+ # the same component or another (+on:+), at a given scope. The template
106
+ # interpolates the selector's named captures, as in
107
+ # <tt>requires("without_%{base}")</tt>. Rule id +naming.requires+.
108
+ class Requires
109
+ def initialize(template, on: nil, scope: :instance, because: nil)
110
+ unless NamingRule::VALID_SCOPES.include?(scope)
111
+ raise Error, "requires scope: must be :instance or :class, got #{scope.inspect}"
112
+ end
113
+ raise Error, "requires expects a String template, got #{template.inspect}" unless template.is_a?(String)
114
+
115
+ @template = template
116
+ @on = on
117
+ @target_scope = scope
118
+ @because = because
119
+ end
120
+
121
+ def id
122
+ 'naming.requires'
123
+ end
124
+
125
+ def diagnostics(selected, rule, graph)
126
+ target = @on || rule.source
127
+ existing = existing_names(graph, target)
128
+
129
+ selected.filter_map do |definition, match|
130
+ sibling = expand(match)
131
+ next if sibling.nil? || existing.include?(sibling.to_sym)
132
+
133
+ Diagnostic.new(
134
+ rule: id,
135
+ message: message_for(definition, sibling, target, rule),
136
+ location: definition.location,
137
+ evidence: "#{definition.owner} defines ##{definition.name}, expected ##{sibling}"
138
+ )
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def existing_names(graph, target)
145
+ definitions = target ? graph.method_definitions_for_component(target) : graph.method_definitions
146
+ definitions.select { |definition| definition.scope == @target_scope }.map(&:name).to_set
147
+ end
148
+
149
+ def expand(match)
150
+ @template % captures(match)
151
+ rescue KeyError, ArgumentError
152
+ nil
153
+ end
154
+
155
+ def captures(match)
156
+ match.is_a?(MatchData) ? match.named_captures.transform_keys(&:to_sym) : {}
157
+ end
158
+
159
+ def message_for(definition, sibling, target, rule)
160
+ clause =
161
+ if target && target != rule.source
162
+ "#{target} to define ##{sibling}"
163
+ else
164
+ "a matching ##{sibling}"
165
+ end
166
+ base = "#{definition.owner}##{definition.name} requires #{clause}"
167
+ @because ? "#{base}: #{@because}" : base
168
+ end
169
+ end
170
+
171
+ # Returned by ArchSpec::DSL::ComponentProxy#method_names. Starts a selector.
172
+ class Builder
173
+ def initialize(component, scope: :instance)
174
+ @component = component
175
+ @scope = scope
176
+ end
177
+
178
+ def matching(regex)
179
+ Selected.new(@component, NameSelector.new(regex), @scope)
180
+ end
181
+ end
182
+
183
+ # A chosen selector, waiting for a constraint. Each constraint method builds
184
+ # a NamingRule, attaches it, and returns the component proxy so rules chain.
185
+ class Selected
186
+ def initialize(component, selector, scope)
187
+ @component = component
188
+ @selector = selector
189
+ @scope = scope
190
+ end
191
+
192
+ def forbidden(except: [], because: nil)
193
+ add(Forbidden.new(because: because), except)
194
+ end
195
+
196
+ def requires(template, on: nil, scope: :instance, except: [], because: nil)
197
+ validate_template!(template)
198
+ add(Requires.new(template, on: component_name(on), scope: scope, because: because), except)
199
+ end
200
+
201
+ private
202
+
203
+ def add(constraint, except)
204
+ rule = NamingRule.new(
205
+ source: @component.name,
206
+ selector: @selector,
207
+ constraint: constraint,
208
+ scope: @scope,
209
+ except: except
210
+ )
211
+ @component.definition.add_rule(rule)
212
+ @component
213
+ end
214
+
215
+ def component_name(target)
216
+ return if target.nil?
217
+
218
+ name = target.respond_to?(:name) ? target.name : target.to_sym
219
+ unless @component.definition.component?(name)
220
+ raise Error, "#{@component.name}.method_names.requires references unknown component: #{name}"
221
+ end
222
+
223
+ name
224
+ end
225
+
226
+ def validate_template!(template)
227
+ return unless template.is_a?(String)
228
+
229
+ captures = @selector.regex.names.to_h { |name| [name.to_sym, name] }
230
+ template % captures
231
+ rescue KeyError, ArgumentError => e
232
+ raise Error, "invalid requires template #{template.inspect}: #{e.message}"
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -36,9 +36,9 @@ module ArchSpec
36
36
  public_names = public_constant_names(graph)
37
37
 
38
38
  graph.dependency_edges.filter_map do |edge|
39
- next if component.files.include?(edge.from_path)
39
+ next if graph.source_components_for(edge).include?(source)
40
40
 
41
- resolved = graph.resolve_constant_reference(edge.to, edge.from_constant)
41
+ resolved = graph.resolve_edge_constant(edge)
42
42
  next unless graph.component_names_for_constant(resolved).include?(source)
43
43
  next if public?(resolved, public_names)
44
44
 
@@ -46,7 +46,7 @@ module ArchSpec
46
46
  rule: id,
47
47
  message: "#{resolved} is private to #{source}",
48
48
  location: edge.location,
49
- evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{resolved}"
49
+ evidence: "#{graph.edge_source_name(edge)} #{edge.verb} #{resolved}"
50
50
  )
51
51
  end
52
52
  end
@@ -35,14 +35,14 @@ module ArchSpec
35
35
  next unless edge.type == :calls_named_method
36
36
  next unless method_names.include?(edge.to.to_sym)
37
37
  next if receiver == :none && edge.receiver != :none
38
- next unless graph.component_names_for_path(edge.from_path).include?(source)
38
+ next unless graph.source_components_for(edge).include?(source)
39
39
  next if own_method_call?(graph, edge)
40
40
 
41
41
  Diagnostic.new(
42
42
  rule: id,
43
43
  message: "#{source} must not call ##{edge.to}",
44
44
  location: edge.location,
45
- evidence: "#{edge.from_constant || edge.from_path} calls #{edge.to}"
45
+ evidence: "#{graph.edge_source_name(edge)} calls #{edge.to}"
46
46
  )
47
47
  end
48
48
  end
@@ -50,7 +50,7 @@ module ArchSpec
50
50
  private
51
51
 
52
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.
53
+ # inherited, or via attr_*/attribute/delegate) is a call to its own API.
54
54
  def own_method_call?(graph, edge)
55
55
  return false unless edge.receiver == :none && edge.from_constant
56
56
 
@@ -107,7 +107,10 @@ module ArchSpec
107
107
 
108
108
  def initialize(source, method_names)
109
109
  @source = source.to_sym
110
- @method_names = Array(method_names).flatten.map(&:to_sym)
110
+ names = Array(method_names).flatten.compact
111
+ raise Error, 'must_implement_one_of requires at least one method' if names.empty?
112
+
113
+ @method_names = names.map(&:to_sym)
111
114
  end
112
115
 
113
116
  def merge_key
@@ -154,7 +157,7 @@ module ArchSpec
154
157
  component = graph.components[source]
155
158
  return [] unless component
156
159
 
157
- component.constants.flat_map { |name| graph.constants_named(name) }.select(&:class?).uniq(&:name)
160
+ graph.constants_for_component(source).select(&:class?).uniq(&:name)
158
161
  end
159
162
 
160
163
  def for(constant, methods, unresolved)
@@ -222,13 +225,13 @@ module ArchSpec
222
225
  def evaluate(graph)
223
226
  graph.edges.filter_map do |edge|
224
227
  next unless edge.type == :instantiates_and_invokes
225
- next unless graph.component_names_for_path(edge.from_path).include?(source)
228
+ next unless graph.source_components_for(edge).include?(source)
226
229
 
227
230
  Diagnostic.new(
228
231
  rule: id,
229
232
  message: "#{source} must not instantiate and immediately invoke #{edge.to}",
230
233
  location: edge.location,
231
- evidence: "#{edge.from_constant || edge.from_path} uses #{edge.to}"
234
+ evidence: "#{graph.edge_source_name(edge)} uses #{edge.to}"
232
235
  )
233
236
  end
234
237
  end
@@ -5,9 +5,15 @@ require 'pathname'
5
5
  require_relative 'value_object'
6
6
 
7
7
  module ArchSpec
8
- SourceLocation = ValueObject.define(:path, :line, :column) do
8
+ SourceLocation = ValueObject.define(:path, :line, :column, :end_line, :end_column) do
9
9
  def self.from_prism(path, location)
10
- new(path, location.start_line, location.start_column + 1)
10
+ new(path, location.start_line, location.start_column + 1, location.end_line, location.end_column + 1)
11
+ end
12
+
13
+ # A zero-width location for diagnostics that point at a file rather than a
14
+ # span of code.
15
+ def self.point(path, line, column)
16
+ new(path, line, column, line, column)
11
17
  end
12
18
 
13
19
  def relative_path(root)
data/lib/archspec/todo.rb CHANGED
@@ -16,11 +16,24 @@ module ArchSpec
16
16
  return empty(root: root) unless path && File.exist?(path)
17
17
 
18
18
  document = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
19
- ids = Array(document['violations']).filter_map do |entry|
20
- entry.is_a?(Hash) ? entry['id'] : entry
19
+ unless document.is_a?(Hash) && (document['violations'].nil? || document['violations'].is_a?(Array))
20
+ raise Error, "invalid todo file #{path}: expected a violations list"
21
+ end
22
+
23
+ ids = Array(document['violations']).map.with_index do |entry, index|
24
+ id = entry.is_a?(Hash) ? entry['id'] : entry
25
+ unless id.is_a?(String) && !id.empty?
26
+ raise Error, "invalid todo file #{path}: violation #{index + 1} has no id"
27
+ end
28
+
29
+ id
21
30
  end
22
31
 
23
32
  new(ids.to_set, root: root)
33
+ rescue Error
34
+ raise
35
+ rescue Psych::Exception, SystemCallError => e
36
+ raise Error, "could not load todo file #{path}: #{e.message}"
24
37
  end
25
38
 
26
39
  def self.write(path, diagnostics, root:)
@@ -37,6 +50,8 @@ module ArchSpec
37
50
  }
38
51
 
39
52
  File.write(path, payload.to_yaml)
53
+ rescue SystemCallError => e
54
+ raise Error, "could not write todo file #{path}: #{e.message}"
40
55
  end
41
56
 
42
57
  def initialize(ids, root:)
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
8
  module ValueObject
5
9
  def self.define(*members, &block)
6
10
  klass = Struct.new(*members) do
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchSpec
4
- VERSION = '0.4.0'
4
+ VERSION = '1.0.0.rc1'
5
5
  end
data/lib/archspec.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'archspec/error'
3
4
  require_relative 'archspec/version'
4
5
  require_relative 'archspec/value_object'
5
6
  require_relative 'archspec/source_location'
@@ -12,16 +13,17 @@ require_relative 'archspec/dsl'
12
13
  require_relative 'archspec/analyzer'
13
14
  require_relative 'archspec/evaluator'
14
15
  require_relative 'archspec/architectures'
15
- require_relative 'archspec/presets'
16
16
  require_relative 'archspec/rules/component_rules'
17
17
  require_relative 'archspec/rules/concern_rules'
18
18
  require_relative 'archspec/rules/dependency_rules'
19
+ require_relative 'archspec/rules/naming_rules'
19
20
  require_relative 'archspec/rules/privacy_rule'
20
21
  require_relative 'archspec/rules/protocol_rules'
21
22
  require_relative 'archspec/rules/cycle_rule'
22
- require_relative 'archspec/rules/zeitwerk_rule'
23
+ require_relative 'archspec/formatters/style'
23
24
  require_relative 'archspec/formatters/text'
24
25
  require_relative 'archspec/formatters/json'
26
+ require_relative 'archspec/formatters/explanation'
25
27
  require_relative 'archspec/cli'
26
28
 
27
29
  # ArchSpec turns your application's architecture into executable checks.
@@ -43,15 +45,7 @@ require_relative 'archspec/cli'
43
45
  #
44
46
  # You can also build a definition in plain Ruby with ArchSpec.define.
45
47
  module ArchSpec
46
- # Raised for configuration and usage errors, such as an unknown architecture
47
- # name or a malformed rule option.
48
- class Error < StandardError; end
49
-
50
48
  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.
53
- attr_accessor :last_definition
54
-
55
49
  # Builds an architecture definition from a block of DSL calls.
56
50
  #
57
51
  # ArchSpec.define do
@@ -60,16 +54,17 @@ module ArchSpec
60
54
  # models.cannot_use :controllers
61
55
  # end
62
56
  #
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.
57
+ # An +Archspec.rb+ file is not written with this wrapper. Its top level is
58
+ # already the DSL, so bare +component+ and +architecture+ calls work
59
+ # directly. Use +define+ when constructing a definition from Ruby, such as
60
+ # in a test.
66
61
  #
67
- # Returns the ArchSpec::Definition, which is also stored as last_definition.
62
+ # Returns the ArchSpec::Definition.
68
63
  def define(name = nil, &block)
69
64
  definition = Definition.new(name)
70
65
  definition.extend(DSL::Context)
71
66
  definition.instance_eval(&block) if block
72
- self.last_definition = definition
67
+ definition
73
68
  end
74
69
  end
75
70
  end
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.4.0
4
+ version: 1.0.0.rc1
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-07-05 00:00:00.000000000 Z
11
+ date: 2026-08-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -93,18 +93,20 @@ files:
93
93
  - lib/archspec/definition.rb
94
94
  - lib/archspec/diagnostic.rb
95
95
  - lib/archspec/dsl.rb
96
+ - lib/archspec/error.rb
96
97
  - lib/archspec/evaluator.rb
98
+ - lib/archspec/formatters/explanation.rb
97
99
  - lib/archspec/formatters/json.rb
100
+ - lib/archspec/formatters/style.rb
98
101
  - lib/archspec/formatters/text.rb
99
102
  - lib/archspec/model.rb
100
- - lib/archspec/presets.rb
101
103
  - lib/archspec/rules/component_rules.rb
102
104
  - lib/archspec/rules/concern_rules.rb
103
105
  - lib/archspec/rules/cycle_rule.rb
104
106
  - lib/archspec/rules/dependency_rules.rb
107
+ - lib/archspec/rules/naming_rules.rb
105
108
  - lib/archspec/rules/privacy_rule.rb
106
109
  - lib/archspec/rules/protocol_rules.rb
107
- - lib/archspec/rules/zeitwerk_rule.rb
108
110
  - lib/archspec/source_location.rb
109
111
  - lib/archspec/todo.rb
110
112
  - lib/archspec/value_object.rb
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ArchSpec
4
- # Backwards-compatible alias for ArchSpec::Architectures. New code should use
5
- # +architecture+ in the DSL and the ArchSpec::Architectures module.
6
- module Presets
7
- module_function
8
-
9
- # Delegates to ArchSpec::Architectures.apply.
10
- def apply(name, dsl, **options)
11
- Architectures.apply(name, dsl, **options)
12
- end
13
- end
14
- end
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ArchSpec
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.
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
-
17
- def id
18
- 'zeitwerk.naming'
19
- end
20
-
21
- def evaluate(graph)
22
- scoped_paths = scoped_paths(graph)
23
-
24
- graph.files.values.filter_map do |file|
25
- next unless file.expected_constant
26
- next if scoped_paths && !scoped_paths.include?(file.path)
27
-
28
- defined = graph.constants_for_path(file.path).map(&:name)
29
- next if defined.include?(file.expected_constant)
30
-
31
- Diagnostic.new(
32
- rule: id,
33
- message: "#{file.relative_path} should define #{file.expected_constant}",
34
- location: SourceLocation.new(file.path, 1, 1),
35
- evidence: "defined constants: #{defined.empty? ? '(none)' : defined.join(', ')}"
36
- )
37
- end
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
49
- end
50
- end
51
- end