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,132 @@
1
+ module ArchSpec
2
+ module DSL
3
+ module Context
4
+ def root(path = nil)
5
+ return root_path unless path
6
+
7
+ self.root_path = path.to_s
8
+ end
9
+
10
+ def source(*patterns)
11
+ add_source_patterns(patterns)
12
+ end
13
+
14
+ def ignore(*patterns)
15
+ add_ignore_patterns(patterns)
16
+ end
17
+
18
+ def baseline(path = ".archspec_todo.yml")
19
+ self.baseline_path = path.to_s
20
+ end
21
+
22
+ def component(name, in: nil, namespace: nil, constants: nil)
23
+ add_component(
24
+ ComponentSpec.new(name, files: binding.local_variable_get(:in), namespace: namespace, constants: constants)
25
+ )
26
+ ComponentProxy.new(self, name)
27
+ end
28
+
29
+ alias layer component
30
+ alias role component
31
+
32
+ def preset(name, **options)
33
+ Presets.apply(name, self, **options)
34
+ end
35
+
36
+ def architecture(name, **options)
37
+ Architectures.apply(name, self, **options)
38
+ end
39
+
40
+ def no_cycles!(among: nil)
41
+ add_rule(Rules::NoCyclesRule.new(among: among))
42
+ end
43
+
44
+ def verify_zeitwerk_names!
45
+ add_rule(Rules::ZeitwerkNamingRule.new)
46
+ end
47
+
48
+ def rule(rule)
49
+ add_rule(rule)
50
+ end
51
+
52
+ def method_missing(name, ...)
53
+ return ComponentProxy.new(self, name) if component?(name)
54
+
55
+ super
56
+ end
57
+
58
+ def respond_to_missing?(name, include_private = false)
59
+ component?(name) || super
60
+ end
61
+ end
62
+
63
+ class ComponentProxy
64
+ attr_reader :definition, :name
65
+
66
+ def initialize(definition, name)
67
+ @definition = definition
68
+ @name = name.to_sym
69
+ end
70
+
71
+ def can_use(*targets)
72
+ add_rule(Rules::AllowDependenciesRule.new(name, targets))
73
+ self
74
+ end
75
+
76
+ alias only_depend_on can_use
77
+ alias must_only_depend_on can_use
78
+
79
+ def cannot_use(*targets)
80
+ add_rule(Rules::ForbidDependenciesRule.new(name, targets))
81
+ self
82
+ end
83
+
84
+ def cannot_call(*methods)
85
+ add_rule(Rules::CannotCallRule.new(name, methods))
86
+ self
87
+ end
88
+
89
+ def cannot_define(*methods)
90
+ add_rule(Rules::CannotDefineMethodRule.new(name, methods))
91
+ self
92
+ end
93
+
94
+ def cannot_instantiate_and_invoke
95
+ add_rule(Rules::CannotInstantiateAndInvokeRule.new(name))
96
+ self
97
+ end
98
+
99
+ def cannot_reference_constants(*constants)
100
+ add_rule(Rules::CannotReferenceConstantsRule.new(name, constants))
101
+ self
102
+ end
103
+
104
+ def must_implement(*methods)
105
+ methods.each do |method_name|
106
+ add_rule(Rules::MustImplementRule.new(name, method_name))
107
+ end
108
+ self
109
+ end
110
+
111
+ def must_implement_one_of(*methods)
112
+ add_rule(Rules::MustImplementOneOfRule.new(name, methods))
113
+ self
114
+ end
115
+
116
+ private
117
+
118
+ def add_rule(rule)
119
+ if rule.respond_to?(:merge_key)
120
+ existing = definition.rules.find do |candidate|
121
+ candidate.respond_to?(:merge_key) && candidate.merge_key == rule.merge_key
122
+ end
123
+
124
+ return existing.merge!(rule) if existing&.respond_to?(:merge!)
125
+ return existing if existing
126
+ end
127
+
128
+ definition.add_rule(rule)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,28 @@
1
+ module ArchSpec
2
+ module Evaluator
3
+ extend self
4
+
5
+ def evaluate(definition, graph, baseline: Baseline.empty)
6
+ (parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) })
7
+ .reject { |diagnostic| graph.suppressed?(diagnostic) }
8
+ .reject { |diagnostic| baseline.include?(diagnostic) }
9
+ .sort_by { |diagnostic| [diagnostic.location.path, diagnostic.location.line, diagnostic.rule, diagnostic.message] }
10
+ end
11
+
12
+ private
13
+
14
+ def parser_diagnostics(graph)
15
+ graph.files.values.flat_map do |file|
16
+ file.parse_errors.map do |parse_error|
17
+ Diagnostic.new(
18
+ rule: "parser.syntax",
19
+ message: parse_error.message,
20
+ location: parse_error.location,
21
+ evidence: file.relative_path,
22
+ confidence: :high
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ require "json"
2
+
3
+ module ArchSpec
4
+ module Formatters
5
+ module JSON
6
+ extend self
7
+
8
+ def print(output = $stdout, graph:, diagnostics:)
9
+ output.puts ::JSON.pretty_generate(
10
+ files: graph.files.size,
11
+ constants: graph.constants.size,
12
+ facts: graph.edges.size,
13
+ violations: diagnostics.map { |diagnostic| diagnostic.to_h(root: graph.root) }
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,26 @@
1
+ module ArchSpec
2
+ module Formatters
3
+ module Text
4
+ extend self
5
+
6
+ def print(output = $stdout, graph:, diagnostics:)
7
+ if diagnostics.empty?
8
+ output.puts "ArchSpec passed: #{graph.files.size} files, #{graph.constants.size} constants, #{graph.edges.size} facts checked."
9
+ return
10
+ end
11
+
12
+ output.puts "#{diagnostics.size} architecture #{diagnostics.size == 1 ? "violation" : "violations"}"
13
+ output.puts
14
+
15
+ diagnostics.each do |diagnostic|
16
+ output.puts "[#{diagnostic.rule}] #{diagnostic.location.relative_path(graph.root)}:#{diagnostic.location.line}:#{diagnostic.location.column}"
17
+ output.puts " #{diagnostic.message}"
18
+ output.puts " evidence: #{diagnostic.evidence}"
19
+ output.puts " confidence: #{diagnostic.confidence}"
20
+ output.puts " id: #{diagnostic.fingerprint(root: graph.root)}"
21
+ output.puts
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,278 @@
1
+ require "pathname"
2
+ require "set"
3
+
4
+ module ArchSpec
5
+ ParseError = Data.define(:message, :location)
6
+ MethodDefinition = Data.define(:owner, :name, :scope, :location)
7
+
8
+ Suppression = Data.define(:rule, :start_line, :end_line, :reason) do
9
+ def matches?(diagnostic)
10
+ (rule.nil? || rule == diagnostic.rule) &&
11
+ diagnostic.location.line >= start_line &&
12
+ diagnostic.location.line <= end_line
13
+ end
14
+ end
15
+
16
+ class SourceFile
17
+ attr_reader :path, :relative_path, :expected_constant, :parse_errors, :suppressions
18
+
19
+ def initialize(root:, path:, expected_constant:, parse_errors:, suppressions:)
20
+ @path = path
21
+ @relative_path = Pathname(path).relative_path_from(Pathname(root)).to_s
22
+ @expected_constant = expected_constant
23
+ @parse_errors = parse_errors
24
+ @suppressions = suppressions
25
+ end
26
+ end
27
+
28
+ class ConstantNode
29
+ attr_reader :name, :kind, :path, :location, :instance_methods, :class_methods, :method_definitions, :mixins
30
+ attr_accessor :superclass
31
+
32
+ def initialize(name:, kind:, path:, location:)
33
+ @name = name
34
+ @kind = kind
35
+ @path = path
36
+ @location = location
37
+ @instance_methods = Set.new
38
+ @class_methods = Set.new
39
+ @method_definitions = []
40
+ @mixins = {
41
+ include: Set.new,
42
+ prepend: Set.new,
43
+ extend: Set.new
44
+ }
45
+ end
46
+
47
+ def class?
48
+ kind == :class
49
+ end
50
+
51
+ def module?
52
+ kind == :module
53
+ end
54
+
55
+ def add_instance_method(name, location:)
56
+ instance_methods.add(name.to_sym)
57
+ method_definitions << MethodDefinition.new(self.name, name.to_sym, :instance, location)
58
+ end
59
+
60
+ def add_class_method(name, location:)
61
+ class_methods.add(name.to_sym)
62
+ method_definitions << MethodDefinition.new(self.name, name.to_sym, :class, location)
63
+ end
64
+
65
+ def add_mixin(kind, name)
66
+ mixins.fetch(kind).add(name)
67
+ end
68
+ end
69
+
70
+ Edge = Data.define(:type, :from_path, :from_constant, :to, :location, :confidence)
71
+
72
+ class Component
73
+ attr_reader :name, :files, :constants, :file_reasons, :constant_reasons
74
+
75
+ def initialize(name)
76
+ @name = name.to_sym
77
+ @files = Set.new
78
+ @constants = Set.new
79
+ @file_reasons = Hash.new { |hash, key| hash[key] = Set.new }
80
+ @constant_reasons = Hash.new { |hash, key| hash[key] = Set.new }
81
+ end
82
+
83
+ def add_file(path, reason: nil)
84
+ files.add(path)
85
+ file_reasons[path].add(reason) if reason
86
+ end
87
+
88
+ def add_constant(name, reason: nil)
89
+ constants.add(name)
90
+ constant_reasons[name].add(reason) if reason
91
+ end
92
+ end
93
+
94
+ class Graph
95
+ DEPENDENCY_EDGE_TYPES = %i[
96
+ references_constant
97
+ inherits_from
98
+ includes
99
+ prepends
100
+ extends
101
+ ].freeze
102
+
103
+ attr_reader :root, :files, :constants, :edges, :components
104
+
105
+ def initialize(root)
106
+ @root = File.expand_path(root)
107
+ @files = {}
108
+ @constants = []
109
+ @constants_by_name = Hash.new { |hash, key| hash[key] = [] }
110
+ @edges = []
111
+ @components = {}
112
+ end
113
+
114
+ def add_file(path:, expected_constant:, parse_errors:, suppressions: [])
115
+ files[path] = SourceFile.new(
116
+ root: root,
117
+ path: path,
118
+ expected_constant: expected_constant,
119
+ parse_errors: parse_errors,
120
+ suppressions: suppressions
121
+ )
122
+ end
123
+
124
+ def add_constant(name:, kind:, path:, location:)
125
+ normalized = normalize_constant(name)
126
+ existing = @constants_by_name[normalized].find { |constant| constant.path == path && constant.kind == kind }
127
+ return existing if existing
128
+
129
+ constant = ConstantNode.new(name: normalized, kind: kind, path: path, location: location)
130
+ constants << constant
131
+ @constants_by_name[normalized] << constant
132
+ constant
133
+ end
134
+
135
+ def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high)
136
+ edges << Edge.new(type, from_path, from_constant, normalize_constant(to), location, confidence)
137
+ end
138
+
139
+ def constants_named(name)
140
+ @constants_by_name[normalize_constant(name)]
141
+ end
142
+
143
+ def constants_for_path(path)
144
+ constants.select { |constant| constant.path == path }
145
+ end
146
+
147
+ def method_definitions_for_component(name)
148
+ component = components.fetch(name.to_sym)
149
+ component.constants.flat_map { |constant_name| constants_named(constant_name) }.flat_map(&:method_definitions)
150
+ rescue KeyError
151
+ []
152
+ end
153
+
154
+ def assign_components(component_specs)
155
+ @components = {}
156
+
157
+ component_specs.each do |spec|
158
+ component = Component.new(spec.name)
159
+
160
+ spec.file_patterns.each do |pattern|
161
+ each_matching_file(pattern) { |path| component.add_file(path, reason: "matched file pattern #{pattern}") }
162
+ end
163
+
164
+ constants.each do |constant|
165
+ matched_file = component.files.include?(constant.path)
166
+ matched_constant = spec.matches_constant?(constant.name)
167
+ next unless matched_file || matched_constant
168
+
169
+ 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
+ end
172
+
173
+ @components[component.name] = component
174
+ end
175
+ end
176
+
177
+ def component_names_for_path(path)
178
+ components.values.each_with_object(Set.new) do |component, names|
179
+ names.add(component.name) if component.files.include?(path)
180
+ end
181
+ end
182
+
183
+ def component_names_for_constant(name)
184
+ normalized = normalize_constant(name)
185
+
186
+ components.values.each_with_object(Set.new) do |component, names|
187
+ names.add(component.name) if component.constants.include?(normalized)
188
+ end
189
+ end
190
+
191
+ def dependency_edges
192
+ edges.select { |edge| DEPENDENCY_EDGE_TYPES.include?(edge.type) }
193
+ end
194
+
195
+ def target_components_for(edge)
196
+ return Set.new unless DEPENDENCY_EDGE_TYPES.include?(edge.type)
197
+
198
+ resolved = resolve_constant_reference(edge.to, edge.from_constant)
199
+ constants_named(resolved).each_with_object(Set.new) do |constant, names|
200
+ names.merge(component_names_for_path(constant.path))
201
+ names.merge(component_names_for_constant(constant.name))
202
+ end
203
+ end
204
+
205
+ def resolve_constant_reference(name, from_constant)
206
+ normalized = normalize_constant(name)
207
+ candidates = []
208
+
209
+ if from_constant
210
+ namespace = normalize_constant(from_constant).split("::")
211
+ namespace.pop
212
+
213
+ until namespace.empty?
214
+ candidates << "#{namespace.join("::")}::#{normalized}"
215
+ namespace.pop
216
+ end
217
+ end
218
+
219
+ candidates << normalized
220
+ candidates.find { |candidate| constants_named(candidate).any? } || normalized
221
+ end
222
+
223
+ def component_dependency_pairs(only: nil)
224
+ allowed_sources = Array(only).compact.map(&:to_sym).to_set
225
+ pairs = Set.new
226
+
227
+ dependency_edges.each do |edge|
228
+ source_components = component_names_for_path(edge.from_path)
229
+ source_components &= allowed_sources unless allowed_sources.empty?
230
+ next if source_components.empty?
231
+
232
+ target_components_for(edge).each do |target|
233
+ source_components.each do |source|
234
+ pairs.add([source, target]) unless source == target
235
+ end
236
+ end
237
+ end
238
+
239
+ pairs
240
+ end
241
+
242
+ def component_assignment_reasons_for_path(path)
243
+ components.values.each_with_object({}) do |component, reasons|
244
+ next unless component.files.include?(path)
245
+
246
+ reasons[component.name] = component.file_reasons[path].to_a.sort
247
+ end
248
+ end
249
+
250
+ def component_assignment_reasons_for_constant(name)
251
+ normalized = normalize_constant(name)
252
+
253
+ components.values.each_with_object({}) do |component, reasons|
254
+ next unless component.constants.include?(normalized)
255
+
256
+ reasons[component.name] = component.constant_reasons[normalized].to_a.sort
257
+ end
258
+ end
259
+
260
+ def suppressed?(diagnostic)
261
+ files[diagnostic.location.path]&.suppressions&.any? { |suppression| suppression.matches?(diagnostic) }
262
+ end
263
+
264
+ private
265
+
266
+ def each_matching_file(pattern)
267
+ glob = File.absolute_path(pattern, root)
268
+ Dir.glob(glob).sort.each do |path|
269
+ expanded = File.expand_path(path)
270
+ yield expanded if files.key?(expanded)
271
+ end
272
+ end
273
+
274
+ def normalize_constant(value)
275
+ value.to_s.sub(/\A::/, "")
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,56 @@
1
+ module ArchSpec
2
+ module Presets
3
+ module_function
4
+
5
+ def apply(name, dsl, **options)
6
+ case name.to_sym
7
+ when :rails_way, :rails_mvc
8
+ rails_way(dsl, **options)
9
+ when :rails_strict
10
+ rails_strict(dsl, **options)
11
+ when :rails_layered
12
+ rails_layered(dsl, **options)
13
+ when :rails_hexagonal
14
+ rails_hexagonal(dsl, **options)
15
+ when :rails_clean
16
+ rails_clean(dsl, **options)
17
+ when :rails_cqrs
18
+ rails_cqrs(dsl, **options)
19
+ when :rails_event_driven
20
+ rails_event_driven(dsl, **options)
21
+ else
22
+ raise Error, "Unknown ArchSpec preset: #{name.inspect}"
23
+ end
24
+ end
25
+
26
+ def rails_way(dsl, **options)
27
+ Architectures.apply(:rails_mvc, dsl, **options)
28
+ end
29
+
30
+ def rails_strict(dsl, **options)
31
+ rails_way(dsl, **options)
32
+ dsl.verify_zeitwerk_names!
33
+ dsl.no_cycles!(among: %i[controllers models helpers mailers jobs services])
34
+ end
35
+
36
+ def rails_layered(dsl, **options)
37
+ Architectures.apply(:layered, dsl, **options)
38
+ end
39
+
40
+ def rails_hexagonal(dsl, **options)
41
+ Architectures.apply(:hexagonal, dsl, **options)
42
+ end
43
+
44
+ def rails_clean(dsl, **options)
45
+ Architectures.apply(:clean, dsl, **options)
46
+ end
47
+
48
+ def rails_cqrs(dsl, **options)
49
+ Architectures.apply(:cqrs, dsl, **options)
50
+ end
51
+
52
+ def rails_event_driven(dsl, **options)
53
+ Architectures.apply(:event_driven, dsl, **options)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,79 @@
1
+ module ArchSpec
2
+ module Rules
3
+ class NoCyclesRule
4
+ attr_reader :components
5
+
6
+ def initialize(among: nil)
7
+ @components = Array(among).compact.map(&:to_sym)
8
+ end
9
+
10
+ def id
11
+ "dependencies.no_cycles"
12
+ end
13
+
14
+ def evaluate(graph)
15
+ cycles(graph).map do |cycle|
16
+ location = first_location_for_cycle(graph, cycle) || SourceLocation.new(graph.root, 1, 1)
17
+ Diagnostic.new(
18
+ rule: id,
19
+ message: "component dependency cycle: #{cycle.join(" -> ")}",
20
+ location: location,
21
+ evidence: cycle.join(" -> ")
22
+ )
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def cycles(graph)
29
+ adjacency = Hash.new { |hash, key| hash[key] = Set.new }
30
+ graph.component_dependency_pairs(only: components).each do |source, target|
31
+ next if source == target
32
+
33
+ adjacency[source].add(target)
34
+ end
35
+
36
+ found = Set.new
37
+ result = []
38
+
39
+ adjacency.keys.each do |start|
40
+ walk(adjacency, start, start, [], found, result)
41
+ end
42
+
43
+ result
44
+ end
45
+
46
+ def walk(adjacency, start, node, path, found, result)
47
+ next_path = path + [node]
48
+
49
+ adjacency[node].each do |target|
50
+ if target == start
51
+ cycle = canonical_cycle(next_path + [start])
52
+ key = cycle.join("\0")
53
+ next if found.include?(key)
54
+
55
+ found.add(key)
56
+ result << cycle
57
+ elsif !path.include?(target)
58
+ walk(adjacency, start, target, next_path, found, result)
59
+ end
60
+ end
61
+ end
62
+
63
+ def canonical_cycle(cycle)
64
+ body = cycle[0...-1]
65
+ rotations = body.each_index.map { |index| body.rotate(index) }
66
+ canonical = rotations.min_by { |rotation| rotation.map(&:to_s) }
67
+ canonical + [canonical.first]
68
+ end
69
+
70
+ def first_location_for_cycle(graph, cycle)
71
+ source, target = cycle
72
+ graph.dependency_edges.find do |edge|
73
+ graph.component_names_for_path(edge.from_path).include?(source) &&
74
+ graph.target_components_for(edge).include?(target)
75
+ end&.location
76
+ end
77
+ end
78
+ end
79
+ end