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.
- checksums.yaml +7 -0
- data/LICENSE +191 -0
- data/README.md +296 -0
- data/lib/rampart/application/command.rb +15 -0
- data/lib/rampart/application/query.rb +15 -0
- data/lib/rampart/application/service.rb +26 -0
- data/lib/rampart/application/transaction.rb +15 -0
- data/lib/rampart/domain/aggregate_root.rb +13 -0
- data/lib/rampart/domain/domain_event.rb +24 -0
- data/lib/rampart/domain/domain_exception.rb +15 -0
- data/lib/rampart/domain/domain_service.rb +10 -0
- data/lib/rampart/domain/entity.rb +20 -0
- data/lib/rampart/domain/value_object.rb +12 -0
- data/lib/rampart/engine_loader.rb +101 -0
- data/lib/rampart/ports/event_bus_port.rb +10 -0
- data/lib/rampart/ports/secondary_port.rb +13 -0
- data/lib/rampart/support/container.rb +28 -0
- data/lib/rampart/support/result.rb +9 -0
- data/lib/rampart/support/types.rb +9 -0
- data/lib/rampart/testing/architecture_matchers.rb +279 -0
- data/lib/rampart/testing/engine_architecture_shared_spec.rb +345 -0
- data/lib/rampart/testing.rb +15 -0
- data/lib/rampart/version.rb +5 -0
- data/lib/rampart.rb +28 -0
- metadata +152 -0
|
@@ -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,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,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
|