rampart-core 0.1.1

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,12 @@
1
+ require "dry-struct"
2
+
3
+ module Rampart
4
+ module Domain
5
+ class ValueObject < Dry::Struct
6
+ # Value objects are compared by their attributes
7
+ # Dry::Struct provides immutability and value equality by default
8
+ end
9
+ end
10
+ end
11
+
12
+
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rampart
4
+ # Generic loader for Rails engines using hexagonal architecture
5
+ #
6
+ # This loader handles auto-discovery and loading of domain, application, and
7
+ # infrastructure components in the correct order. It works around the mismatch
8
+ # between directory structure (app/{layer}/{context}/) and Ruby namespace
9
+ # conventions by using explicit eager loading.
10
+ #
11
+ # Usage in your engine:
12
+ # module YourContext
13
+ # class Engine < ::Rails::Engine
14
+ # config.to_prepare do
15
+ # Rampart::EngineLoader.load_all(
16
+ # engine_root: YourContext::Engine.root,
17
+ # context_name: "your_context"
18
+ # )
19
+ # end
20
+ # end
21
+ # end
22
+ class EngineLoader
23
+ class << self
24
+ # Load all hexagonal architecture components for an engine
25
+ #
26
+ # @param engine_root [Pathname] Root path of the Rails engine
27
+ # @param context_name [String] Snake-case name of the bounded context (e.g., "cat_content")
28
+ def load_all(engine_root:, context_name:)
29
+ load_domain_layer(engine_root, context_name)
30
+ load_application_layer(engine_root, context_name)
31
+ load_infrastructure_layer(engine_root, context_name)
32
+ end
33
+
34
+ private
35
+
36
+ def load_domain_layer(root, context_name)
37
+ domain = root.join("app/domain/#{context_name}")
38
+ return unless domain.exist?
39
+
40
+ # Load files in specific order: errors, value objects, entities, events, aggregates, services, ports
41
+ load_directory(domain, pattern: "*_error.rb")
42
+ load_directory(domain, pattern: "*_exception.rb")
43
+ load_directory(domain.join("value_objects"))
44
+ load_directory(domain.join("entities"))
45
+ load_directory(domain.join("events"))
46
+ load_directory(domain.join("aggregates"))
47
+ load_directory(domain.join("services"))
48
+ load_directory(domain.join("ports"))
49
+
50
+ # Load any remaining files at domain root
51
+ load_directory(domain, pattern: "*.rb")
52
+ end
53
+
54
+ def load_application_layer(root, context_name)
55
+ app = root.join("app/application/#{context_name}")
56
+ return unless app.exist?
57
+
58
+ # Load all application layer files
59
+ load_directory(app.join("commands"))
60
+ load_directory(app.join("queries"))
61
+ load_directory(app.join("services"))
62
+ load_directory(app, pattern: "*.rb")
63
+ end
64
+
65
+ def load_infrastructure_layer(root, context_name)
66
+ infra = root.join("app/infrastructure/#{context_name}")
67
+ return unless infra.exist?
68
+
69
+ # Load persistence layer first (base_record before models)
70
+ persistence = infra.join("persistence")
71
+ if persistence.exist?
72
+ load_directory(persistence, pattern: "base_record.rb")
73
+ load_directory(persistence.join("models"))
74
+ load_directory(persistence.join("mappers"))
75
+ load_directory(persistence.join("repositories"))
76
+ end
77
+
78
+ # Load other infrastructure components
79
+ load_directory(infra.join("adapters"))
80
+ load_directory(infra.join("http"))
81
+ load_directory(infra.join("wiring"))
82
+ load_directory(infra, pattern: "*.rb")
83
+ end
84
+
85
+ def load_directory(dir, pattern: "**/*.rb")
86
+ return unless dir.exist?
87
+
88
+ Dir.glob(dir.join(pattern)).sort.each do |file|
89
+ next if File.directory?(file)
90
+ if Rails.env.development? || Rails.env.test?
91
+ load file.to_s
92
+ else
93
+ require_dependency file
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rampart
4
+ module Ports
5
+ class EventBusPort < SecondaryPort
6
+ # Publish one or many domain events to downstream subscribers or transports.
7
+ abstract_method :publish
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ module Rampart
2
+ module Ports
3
+ class SecondaryPort
4
+ def self.abstract_method(*names)
5
+ names.each do |name|
6
+ define_method(name) { |*| raise NotImplementedError, "#{self.class}##{name}" }
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+
@@ -0,0 +1,28 @@
1
+ require "dry-container"
2
+ require "dry-auto_inject"
3
+
4
+ module Rampart
5
+ # Base container for dependency registration.
6
+ # Each bounded context should create its own container extending this.
7
+ #
8
+ # Example:
9
+ # module CatContent
10
+ # class Container
11
+ # extend Dry::Container::Mixin
12
+ #
13
+ # register(:cat_listing_repo) { Infrastructure::CatListingRepository.new }
14
+ # register(:id_generator) { Infrastructure::UuidGenerator.new }
15
+ # register(:clock) { Infrastructure::SystemClock.new }
16
+ # register(:transaction) { Infrastructure::ActiveRecordTransaction.new }
17
+ # register(:event_bus) { Infrastructure::EventBus.new }
18
+ # end
19
+ #
20
+ # Import = Dry::AutoInject(Container)
21
+ # end
22
+ module Container
23
+ def self.included(base)
24
+ base.extend(Dry::Container::Mixin)
25
+ end
26
+ end
27
+ end
28
+
@@ -0,0 +1,9 @@
1
+ require "dry-monads"
2
+
3
+ module Rampart
4
+ module Result
5
+ include Dry::Monads[:result]
6
+ end
7
+ end
8
+
9
+
@@ -0,0 +1,9 @@
1
+ require "dry-types"
2
+
3
+ module Rampart
4
+ module Types
5
+ include Dry.Types()
6
+ end
7
+ end
8
+
9
+
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+ require "ripper"
3
+
4
+ module Rampart
5
+ module Testing
6
+ # RSpec matchers for enforcing architectural rules in Rampart applications.
7
+ # These matchers verify layer boundaries, dependency injection policies,
8
+ # immutability, and structural contracts.
9
+ module ArchitectureMatchers
10
+ extend RSpec::Matchers::DSL
11
+
12
+ # Helper methods for AST analysis using Ripper.
13
+ # Encapsulates the complexity of parsing Ruby code to finding method calls,
14
+ # constant references (legacy), and variable mutations.
15
+ class MatcherHelpers
16
+ # Returns the source file path for a given class.
17
+ def self.source_file_for(klass)
18
+ Object.const_source_location(klass.name)&.first
19
+ end
20
+
21
+ # Returns an array of file:line locations where instance variables are mutated
22
+ # (assigned to) outside of the initialize method.
23
+ def self.mutable_instance_var_locations(source_file)
24
+ sexp = Ripper.sexp(File.read(source_file))
25
+ return [] unless sexp
26
+
27
+ collect_ivasgn(sexp, nil, [], source_file).uniq
28
+ end
29
+
30
+ # Recursively collects instance variable assignments (:ivasgn) from the AST.
31
+ # Ignores assignments within the 'initialize' method.
32
+ def self.collect_ivasgn(node, current_method, mutations, source_file)
33
+ return mutations unless node.is_a?(Array)
34
+
35
+ case node[0]
36
+ when :def
37
+ method_name = node[1][1]
38
+ body = node[3]
39
+ collect_ivasgn(body, method_name, mutations, source_file)
40
+ when :defs
41
+ method_name = node[2][1]
42
+ body = node[4]
43
+ collect_ivasgn(body, method_name, mutations, source_file)
44
+ when :ivasgn
45
+ token = node[1]
46
+ line = token[2][0] if token.is_a?(Array)
47
+ mutations << "#{source_file}:#{line}" if current_method != "initialize"
48
+ else
49
+ node.each do |child|
50
+ collect_ivasgn(child, current_method, mutations, source_file)
51
+ end
52
+ end
53
+
54
+ mutations
55
+ end
56
+
57
+ # Scans ruby content for `resolve(:key)` method calls.
58
+ # Yields the key (symbol/string) and line number for each occurrence.
59
+ # Used to verify DI wiring in controllers/services.
60
+ def self.scan_resolves(content, &block)
61
+ sexp = Ripper.sexp(content)
62
+ return unless sexp
63
+
64
+ recursive_scan_resolves(sexp, &block)
65
+ end
66
+
67
+ # Recursively traverses AST looking for method calls named "resolve".
68
+ # Supports `resolve(:key)` and `resolve :key`.
69
+ def self.recursive_scan_resolves(node, &block)
70
+ return unless node.is_a?(Array)
71
+
72
+ if node[0] == :method_add_arg
73
+ # Case: resolve(:key) -> [:method_add_arg, call_node, args_node]
74
+ call_node = node[1]
75
+ args_node = node[2]
76
+
77
+ method_name = extract_method_name(call_node)
78
+ if method_name == "resolve"
79
+ key = extract_symbol_from_args(args_node)
80
+ line = extract_line_number(call_node)
81
+ yield(key, line) if key
82
+ end
83
+ elsif node[0] == :command || node[0] == :command_call
84
+ # Case: resolve :key -> [:command, name, args]
85
+ method_name = extract_method_name(node)
86
+ if method_name == "resolve"
87
+ # Args are usually the last element
88
+ args_node = node.last
89
+ key = extract_symbol_from_args(args_node)
90
+ line = extract_line_number(node)
91
+ yield(key, line) if key
92
+ end
93
+ end
94
+
95
+ node.each do |child|
96
+ recursive_scan_resolves(child, &block) if child.is_a?(Array)
97
+ end
98
+ end
99
+
100
+ # Extracts method name from various call node types (:fcall, :call, :command, etc.)
101
+ def self.extract_method_name(node)
102
+ if node[0] == :fcall || node[0] == :command
103
+ # [:fcall, [:@ident, "resolve", ...]]
104
+ return node[1][1]
105
+ elsif node[0] == :call
106
+ # [:call, receiver, :., [:@ident, "resolve", ...]]
107
+ return node[3][1]
108
+ elsif node[0] == :command_call
109
+ # [:command_call, receiver, :., [:@ident, "resolve", ...], args]
110
+ return node[3][1]
111
+ end
112
+ nil
113
+ end
114
+
115
+ # Extracts the first symbol argument from an arguments node list.
116
+ def self.extract_symbol_from_args(node)
117
+ found = nil
118
+ traverse_for_symbol(node) { |s| found = s }
119
+ found
120
+ end
121
+
122
+ # Traverses argument nodes to find a symbol literal.
123
+ def self.traverse_for_symbol(node, &block)
124
+ return unless node.is_a?(Array)
125
+ if node[0] == :symbol_literal
126
+ symbol_node = node[1]
127
+ if symbol_node[0] == :symbol
128
+ content = symbol_node[1]
129
+ # Handle :@ident (bare symbol) or :@tstring_content (quoted symbol)
130
+ if content[0] == :@ident || content[0] == :@tstring_content
131
+ yield content[1]
132
+ end
133
+ end
134
+ end
135
+ node.each { |c| traverse_for_symbol(c, &block) if c.is_a?(Array) }
136
+ end
137
+
138
+ # Extracts line number from a node or its children.
139
+ def self.extract_line_number(node)
140
+ line = nil
141
+ traverse_for_line(node) { |l| line = l; break }
142
+ line
143
+ end
144
+
145
+ # Traverses node to find the first token with line number information.
146
+ def self.traverse_for_line(node, &block)
147
+ return unless node.is_a?(Array)
148
+ node.each do |child|
149
+ # Token structure: [type, value, [line, col]]
150
+ if child.is_a?(Array) && child.size >= 2 && child.last.is_a?(Array) && child.last.size == 2 && child.last.first.is_a?(Integer)
151
+ yield child.last.first
152
+ else
153
+ traverse_for_line(child, &block)
154
+ end
155
+ end
156
+ end
157
+
158
+ # Returns ancestors of a class that are Rails components.
159
+ def self.rails_ancestors_for(klass)
160
+ rails_prefixes = %w[ActiveRecord ActionDispatch ActionController ActiveSupport Rails]
161
+ shared = Object.ancestors
162
+ klass.ancestors.compact.reject { |ancestor| shared.include?(ancestor) }.select do |ancestor|
163
+ rails_prefixes.any? { |prefix| ancestor.name&.start_with?(prefix) }
164
+ end
165
+ end
166
+ end
167
+
168
+ # Verifies that a class does not inherit from any Rails classes/modules
169
+ # (ActiveRecord, ActionController, etc.).
170
+ matcher :have_no_rails_dependencies do
171
+ match do |klass|
172
+ @rails_ancestors = MatcherHelpers.rails_ancestors_for(klass)
173
+ @rails_ancestors.empty?
174
+ end
175
+
176
+ failure_message do |klass|
177
+ "expected #{klass} to avoid Rails dependencies, found #{@rails_ancestors.map(&:name).join(', ')}"
178
+ end
179
+ end
180
+
181
+ # Verifies that a class inherits from a specific Rampart base class.
182
+ matcher :inherit_from_rampart_base do |base_class|
183
+ match do |klass|
184
+ klass < base_class
185
+ end
186
+
187
+ failure_message do |klass|
188
+ "expected #{klass} to inherit from #{base_class}, but ancestors are: #{klass.ancestors.take(5).map(&:name).join(' -> ')}"
189
+ end
190
+ end
191
+
192
+ # Verifies that a class implements all abstract methods defined in a Port module.
193
+ # Checks that the method is defined on the class itself, not just inherited/mixed in from the port.
194
+ matcher :implement_all_abstract_methods do |port_class|
195
+ match do |implementation_class|
196
+ @abstract_methods = port_class.instance_methods(false)
197
+ @missing_methods = @abstract_methods.reject do |method_name|
198
+ implementation_class.instance_methods.include?(method_name) &&
199
+ implementation_class.instance_method(method_name).owner != port_class
200
+ end
201
+ @missing_methods.empty?
202
+ end
203
+
204
+ failure_message do |implementation_class|
205
+ "expected #{implementation_class} to implement #{port_class} abstract methods: #{@missing_methods.join(', ')}"
206
+ end
207
+ end
208
+
209
+ # Verifies that a class has no public setter methods (ending in `=`).
210
+ matcher :be_immutable do
211
+ match do |klass|
212
+ @setter_methods = klass.instance_methods(false).grep(/=$/).reject { |name| name == :== }
213
+ @setter_methods.empty?
214
+ end
215
+
216
+ failure_message do |klass|
217
+ "expected #{klass} to be immutable, found setter methods: #{@setter_methods.join(', ')}"
218
+ end
219
+ end
220
+
221
+ # Verifies that a class does not mutate instance variables outside of `initialize`.
222
+ # Uses static analysis (Ripper) to find assignments.
223
+ matcher :have_no_mutable_instance_variables do
224
+ match do |klass|
225
+ source_file = MatcherHelpers.source_file_for(klass)
226
+ return true unless source_file
227
+
228
+ @mutation_locations = MatcherHelpers.mutable_instance_var_locations(source_file)
229
+ @mutation_locations.empty?
230
+ end
231
+
232
+ failure_message do |klass|
233
+ "expected #{klass} to avoid instance variable mutation outside initialize, found assignments at: #{@mutation_locations.join(', ')}"
234
+ end
235
+ end
236
+
237
+ # Verifies that an object instance has dependencies (instance variables) of expected types.
238
+ # dependency_types: { ivar_name: ExpectedClass }
239
+ matcher :have_dependencies do |dependency_types|
240
+ match do |object|
241
+ @mismatches = []
242
+ dependency_types.each do |ivar_name, expected_type|
243
+ value = object.instance_variable_get("@#{ivar_name}")
244
+ if value.nil?
245
+ @mismatches << "Missing dependency @#{ivar_name}"
246
+ elsif !value.is_a?(expected_type) && !value.class.ancestors.include?(expected_type)
247
+ @mismatches << "Dependency @#{ivar_name} is a #{value.class}, expected #{expected_type}"
248
+ end
249
+ end
250
+ @mismatches.empty?
251
+ end
252
+
253
+ failure_message do |object|
254
+ "expected #{object} to have correct dependencies:\n#{@mismatches.join("\n")}"
255
+ end
256
+ end
257
+
258
+ # Verifies that a source file only calls `resolve(:key)` for keys in the allowed list.
259
+ # Uses static analysis to find `resolve` calls.
260
+ matcher :only_resolve_allowed_dependencies do |allowed_keys|
261
+ match do |file_path|
262
+ @violations = []
263
+ content = File.read(file_path)
264
+
265
+ MatcherHelpers.scan_resolves(content) do |key, line|
266
+ unless allowed_keys.include?(key.to_sym)
267
+ @violations << "Line #{line}: resolves :#{key} (not in allowed list)"
268
+ end
269
+ end
270
+ @violations.empty?
271
+ end
272
+
273
+ failure_message do |file_path|
274
+ "expected #{file_path} to only resolve allowed dependencies:\n#{@violations.join("\n")}\nAllowed: #{allowed_keys.sort.join(', ')}"
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end