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.
@@ -0,0 +1,117 @@
1
+ require "set"
2
+
3
+ module ArchSpec
4
+ module Rules
5
+ class DependencyRule
6
+ attr_reader :source, :targets
7
+
8
+ def initialize(source, targets)
9
+ @source = source.to_sym
10
+ @targets = Array(targets).flatten.map(&:to_sym).to_set
11
+ end
12
+
13
+ def merge_key
14
+ [self.class, source]
15
+ end
16
+
17
+ def merge!(other)
18
+ targets.merge(other.targets)
19
+ self
20
+ end
21
+
22
+ private
23
+
24
+ def relevant_edges(graph)
25
+ graph.dependency_edges.select { |edge| graph.component_names_for_path(edge.from_path).include?(source) }
26
+ end
27
+
28
+ def target_components(graph, edge)
29
+ graph.target_components_for(edge)
30
+ end
31
+
32
+ def edge_target(edge)
33
+ edge.to
34
+ end
35
+ end
36
+
37
+ class AllowDependenciesRule < DependencyRule
38
+ def id
39
+ "dependencies.allow"
40
+ end
41
+
42
+ def evaluate(graph)
43
+ relevant_edges(graph).flat_map do |edge|
44
+ target_components(graph, edge).filter_map do |target|
45
+ next if target == source || targets.include?(target)
46
+
47
+ Diagnostic.new(
48
+ rule: id,
49
+ message: "#{source} may not depend on #{target}",
50
+ location: edge.location,
51
+ evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge_target(edge)}"
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ class ForbidDependenciesRule < DependencyRule
59
+ def id
60
+ "dependencies.forbid"
61
+ end
62
+
63
+ def evaluate(graph)
64
+ relevant_edges(graph).flat_map do |edge|
65
+ forbidden = target_components(graph, edge) & targets
66
+
67
+ forbidden.map do |target|
68
+ Diagnostic.new(
69
+ rule: id,
70
+ message: "#{source} must not depend on #{target}",
71
+ location: edge.location,
72
+ evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge_target(edge)}"
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ class CannotReferenceConstantsRule
80
+ attr_reader :source, :constants
81
+
82
+ def initialize(source, constants)
83
+ @source = source.to_sym
84
+ @constants = Array(constants).flatten.map { |constant| constant.to_s.sub(/\A::/, "") }
85
+ end
86
+
87
+ def merge_key
88
+ [self.class, source]
89
+ end
90
+
91
+ def merge!(other)
92
+ @constants |= other.constants
93
+ self
94
+ end
95
+
96
+ def id
97
+ "constants.forbid"
98
+ end
99
+
100
+ def evaluate(graph)
101
+ graph.dependency_edges.filter_map do |edge|
102
+ next unless graph.component_names_for_path(edge.from_path).include?(source)
103
+
104
+ referenced = graph.resolve_constant_reference(edge.to, edge.from_constant)
105
+ next unless constants.any? { |constant| referenced == constant || referenced.start_with?("#{constant}::") }
106
+
107
+ Diagnostic.new(
108
+ rule: id,
109
+ message: "#{source} must not reference #{referenced}",
110
+ location: edge.location,
111
+ evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge.to}"
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,186 @@
1
+ module ArchSpec
2
+ module Rules
3
+ class CannotCallRule
4
+ attr_reader :source, :method_names
5
+
6
+ def initialize(source, methods)
7
+ @source = source.to_sym
8
+ @method_names = Array(methods).flatten.map(&:to_sym)
9
+ end
10
+
11
+ def merge_key
12
+ [self.class, source]
13
+ end
14
+
15
+ def merge!(other)
16
+ @method_names |= other.method_names
17
+ self
18
+ end
19
+
20
+ def id
21
+ "methods.forbid"
22
+ end
23
+
24
+ def evaluate(graph)
25
+ graph.edges.filter_map do |edge|
26
+ next unless edge.type == :calls_named_method
27
+ next unless method_names.include?(edge.to.to_sym)
28
+ next unless graph.component_names_for_path(edge.from_path).include?(source)
29
+
30
+ Diagnostic.new(
31
+ rule: id,
32
+ message: "#{source} must not call ##{edge.to}",
33
+ location: edge.location,
34
+ evidence: "#{edge.from_constant || edge.from_path} calls #{edge.to}"
35
+ )
36
+ end
37
+ end
38
+ end
39
+
40
+ class MustImplementRule
41
+ attr_reader :source, :method_name
42
+
43
+ def initialize(source, method_name)
44
+ @source = source.to_sym
45
+ @method_name = method_name.to_sym
46
+ end
47
+
48
+ def merge_key
49
+ [self.class, source, method_name]
50
+ end
51
+
52
+ def id
53
+ "protocol.must_implement"
54
+ end
55
+
56
+ def evaluate(graph)
57
+ constants_for(graph).filter_map do |constant|
58
+ next if constant.instance_methods.include?(method_name)
59
+
60
+ Diagnostic.new(
61
+ rule: id,
62
+ message: "#{constant.name} must implement ##{method_name}",
63
+ location: constant.location,
64
+ evidence: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(", ")}"
65
+ )
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def constants_for(graph)
72
+ graph.components.fetch(source).constants.flat_map { |name| graph.constants_named(name) }.select(&:class?)
73
+ rescue KeyError
74
+ []
75
+ end
76
+ end
77
+
78
+ class MustImplementOneOfRule
79
+ attr_reader :source, :method_names
80
+
81
+ def initialize(source, method_names)
82
+ @source = source.to_sym
83
+ @method_names = Array(method_names).flatten.map(&:to_sym)
84
+ end
85
+
86
+ def merge_key
87
+ [self.class, source]
88
+ end
89
+
90
+ def merge!(other)
91
+ @method_names |= other.method_names
92
+ self
93
+ end
94
+
95
+ def id
96
+ "protocol.must_implement_one_of"
97
+ end
98
+
99
+ def evaluate(graph)
100
+ constants_for(graph).filter_map do |constant|
101
+ next if method_names.any? { |method_name| constant.instance_methods.include?(method_name) }
102
+
103
+ Diagnostic.new(
104
+ rule: id,
105
+ message: "#{constant.name} must implement one of #{method_names.map { |name| "##{name}" }.join(", ")}",
106
+ location: constant.location,
107
+ evidence: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(", ")}"
108
+ )
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def constants_for(graph)
115
+ graph.components.fetch(source).constants.flat_map { |name| graph.constants_named(name) }.select(&:class?)
116
+ rescue KeyError
117
+ []
118
+ end
119
+ end
120
+
121
+ class CannotDefineMethodRule
122
+ attr_reader :source, :method_names
123
+
124
+ def initialize(source, methods)
125
+ @source = source.to_sym
126
+ @method_names = Array(methods).flatten.map(&:to_sym)
127
+ end
128
+
129
+ def merge_key
130
+ [self.class, source]
131
+ end
132
+
133
+ def merge!(other)
134
+ @method_names |= other.method_names
135
+ self
136
+ end
137
+
138
+ def id
139
+ "methods.define_forbid"
140
+ end
141
+
142
+ def evaluate(graph)
143
+ graph.method_definitions_for_component(source).filter_map do |method_definition|
144
+ next unless method_names.include?(method_definition.name)
145
+
146
+ Diagnostic.new(
147
+ rule: id,
148
+ message: "#{source} must not define ##{method_definition.name}",
149
+ location: method_definition.location,
150
+ evidence: "#{method_definition.owner} defines #{method_definition.scope} method #{method_definition.name}"
151
+ )
152
+ end
153
+ end
154
+ end
155
+
156
+ class CannotInstantiateAndInvokeRule
157
+ attr_reader :source
158
+
159
+ def initialize(source)
160
+ @source = source.to_sym
161
+ end
162
+
163
+ def merge_key
164
+ [self.class, source]
165
+ end
166
+
167
+ def id
168
+ "objects.instantiate_and_invoke_forbid"
169
+ end
170
+
171
+ def evaluate(graph)
172
+ graph.edges.filter_map do |edge|
173
+ next unless edge.type == :instantiates_and_invokes
174
+ next unless graph.component_names_for_path(edge.from_path).include?(source)
175
+
176
+ Diagnostic.new(
177
+ rule: id,
178
+ message: "#{source} must not instantiate and immediately invoke #{edge.to}",
179
+ location: edge.location,
180
+ evidence: "#{edge.from_constant || edge.from_path} uses #{edge.to}"
181
+ )
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,25 @@
1
+ module ArchSpec
2
+ module Rules
3
+ class ZeitwerkNamingRule
4
+ def id
5
+ "zeitwerk.naming"
6
+ end
7
+
8
+ def evaluate(graph)
9
+ graph.files.values.filter_map do |file|
10
+ next unless file.expected_constant
11
+
12
+ defined = graph.constants_for_path(file.path).map(&:name)
13
+ next if defined.include?(file.expected_constant)
14
+
15
+ Diagnostic.new(
16
+ rule: id,
17
+ message: "#{file.relative_path} should define #{file.expected_constant}",
18
+ location: SourceLocation.new(file.path, 1, 1),
19
+ evidence: "defined constants: #{defined.empty? ? "(none)" : defined.join(", ")}"
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ require "pathname"
2
+
3
+ module ArchSpec
4
+ SourceLocation = Data.define(:path, :line, :column) do
5
+ def self.from_prism(path, location)
6
+ new(path, location.start_line, location.start_column + 1)
7
+ end
8
+
9
+ def relative_path(root)
10
+ Pathname(path).relative_path_from(Pathname(root)).to_s
11
+ rescue ArgumentError
12
+ path
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module ArchSpec
2
+ VERSION = "0.1.0.pre1"
3
+ end
data/lib/archspec.rb ADDED
@@ -0,0 +1,34 @@
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"
20
+
21
+ module ArchSpec
22
+ class Error < StandardError; end
23
+
24
+ class << self
25
+ attr_accessor :last_definition
26
+
27
+ def define(name = "Architecture", &block)
28
+ definition = Definition.new(name)
29
+ definition.extend(DSL::Context)
30
+ definition.instance_eval(&block) if block
31
+ self.last_definition = definition
32
+ end
33
+ end
34
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: archspec
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.pre1
5
+ platform: ruby
6
+ authors:
7
+ - Carmine Paolino
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-06-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: prism
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '5.20'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.20'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ description: Convention-aware architecture checks for Ruby and Rails codebases.
56
+ email:
57
+ - carmine@paolino.me
58
+ executables:
59
+ - archspec
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - LICENSE.txt
64
+ - README.md
65
+ - exe/archspec
66
+ - lib/archspec.rb
67
+ - lib/archspec/analyzer.rb
68
+ - lib/archspec/architectures.rb
69
+ - lib/archspec/baseline.rb
70
+ - lib/archspec/cli.rb
71
+ - lib/archspec/component_spec.rb
72
+ - lib/archspec/definition.rb
73
+ - lib/archspec/diagnostic.rb
74
+ - lib/archspec/dsl.rb
75
+ - lib/archspec/evaluator.rb
76
+ - lib/archspec/formatters/json.rb
77
+ - lib/archspec/formatters/text.rb
78
+ - lib/archspec/model.rb
79
+ - lib/archspec/presets.rb
80
+ - lib/archspec/rules/cycle_rule.rb
81
+ - lib/archspec/rules/dependency_rules.rb
82
+ - lib/archspec/rules/protocol_rules.rb
83
+ - lib/archspec/rules/zeitwerk_rule.rb
84
+ - lib/archspec/source_location.rb
85
+ - lib/archspec/version.rb
86
+ homepage: https://crmne.github.io/archspec
87
+ licenses:
88
+ - MIT
89
+ metadata:
90
+ homepage_uri: https://crmne.github.io/archspec
91
+ source_code_uri: https://github.com/crmne/archspec
92
+ changelog_uri: https://github.com/crmne/archspec/releases
93
+ documentation_uri: https://crmne.github.io/archspec/getting-started/
94
+ bug_tracker_uri: https://github.com/crmne/archspec/issues
95
+ rubygems_mfa_required: 'true'
96
+ post_install_message:
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '3.3'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.5.22
112
+ signing_key:
113
+ specification_version: 4
114
+ summary: Architecture fitness functions for Ruby and Rails.
115
+ test_files: []