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,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rampart/testing/architecture_matchers"
5
+
6
+ # Shared RSpec examples for enforcing Rampart engine architectural patterns.
7
+ #
8
+ # Usage:
9
+ # RSpec.describe "Architecture", type: :architecture do
10
+ # it_behaves_like "Rampart Engine Architecture",
11
+ # engine_root: File.expand_path("../../..", __FILE__),
12
+ # container_class: MyEngine::Infrastructure::Wiring::Container
13
+ # end
14
+ #
15
+ # Options:
16
+ # - engine_root: (required) Path to the engine root directory
17
+ # - container_class: (required) The DI container class for the engine
18
+ # - architecture_json_path: (optional) Path to the architecture JSON file
19
+ # - warn_unimplemented: (optional, default: false) When true, items defined in JSON
20
+ # but missing from code will emit warnings instead of failures. This is useful
21
+ # during development when the JSON blueprint represents planned architecture
22
+ # that hasn't been implemented yet. Code that exists but isn't documented in
23
+ # JSON will still fail (to catch architecture drift).
24
+ #
25
+ # This shared group verifies:
26
+ # 1. DI and Wiring Policies (Services depend on Ports, Controllers depend on Services)
27
+ # 2. Base Class Contracts (Inheritance from Rampart primitives)
28
+ # 3. Immutability (Aggregates and Value Objects are immutable)
29
+ # 4. Public API (No leakage of internal types like ActiveRecord)
30
+ # 5. Architecture JSON Sync (Bidirectional check of code vs blueprint)
31
+ RSpec.shared_examples "Rampart Engine Architecture" do |options = {}|
32
+ include Rampart::Testing::ArchitectureMatchers
33
+
34
+ let(:engine_root) do
35
+ options[:engine_root] || raise("Must provide :engine_root to shared examples")
36
+ end
37
+
38
+ let(:container_class) do
39
+ options[:container_class] || raise("Must provide :container_class (e.g. MyEngine::Infrastructure::Wiring::Container)")
40
+ end
41
+
42
+ let(:architecture_json_path) do
43
+ options[:architecture_json_path] || begin
44
+ bc_id = File.basename(engine_root)
45
+ File.join(engine_root, "../../architecture/#{bc_id}/architecture.json")
46
+ end
47
+ end
48
+
49
+ let(:warn_unimplemented) do
50
+ options[:warn_unimplemented] || false
51
+ end
52
+
53
+ # Helper to check architecture sync with support for warning-only mode on unimplemented items
54
+ def check_architecture_sync(component_type:, json_items:, code_items:, json_path:, add_to_code_hint:, add_to_json_hint:)
55
+ missing_from_code = json_items - code_items
56
+ missing_from_json = code_items - json_items
57
+
58
+ # Always fail if code exists that isn't documented in JSON (architecture drift)
59
+ if missing_from_json.any?
60
+ error_parts = []
61
+ error_parts << "#{component_type} mismatch - undocumented code found:"
62
+ error_parts << ""
63
+ error_parts << " JSON defines: #{json_items.inspect}"
64
+ error_parts << " Code defines: #{code_items.inspect}"
65
+ error_parts << ""
66
+ error_parts << " ❌ Defined in code but missing from JSON: #{missing_from_json.inspect}"
67
+ error_parts << " → #{add_to_json_hint}"
68
+ fail error_parts.join("\n")
69
+ end
70
+
71
+ # For items in JSON but missing from code: warn or fail based on option
72
+ if missing_from_code.any?
73
+ message_parts = []
74
+ message_parts << "#{component_type} not yet implemented:"
75
+ message_parts << ""
76
+ message_parts << " JSON defines: #{json_items.inspect}"
77
+ message_parts << " Code defines: #{code_items.inspect}"
78
+ message_parts << ""
79
+ message_parts << " ⚠️ Defined in JSON but missing from code: #{missing_from_code.inspect}"
80
+ message_parts << " → #{add_to_code_hint}"
81
+ message = message_parts.join("\n")
82
+
83
+ if warn_unimplemented
84
+ # Emit as RSpec warning (will appear in output but not fail)
85
+ warn "\n#{message}\n"
86
+ else
87
+ fail message
88
+ end
89
+ end
90
+ end
91
+
92
+ def classes_in_engine(base_class)
93
+ ObjectSpace.each_object(Class).select do |klass|
94
+ next unless klass < base_class
95
+
96
+ location = Object.const_source_location(klass.name)&.first
97
+ location && location.start_with?(engine_root)
98
+ end
99
+ end
100
+
101
+ let(:architecture_json) do
102
+ JSON.parse(File.read(architecture_json_path))
103
+ end
104
+
105
+ # Helpers for class discovery
106
+ let(:aggregates) { classes_in_engine(Rampart::Domain::AggregateRoot) }
107
+ let(:entities) { classes_in_engine(Rampart::Domain::Entity) }
108
+ let(:value_objects) { classes_in_engine(Rampart::Domain::ValueObject) }
109
+ let(:events) { classes_in_engine(Rampart::Domain::DomainEvent) }
110
+ let(:services) { classes_in_engine(Rampart::Application::Service) }
111
+ let(:ports) do
112
+ classes_in_engine(Rampart::Ports::SecondaryPort).select do |klass|
113
+ location = Object.const_source_location(klass.name)&.first
114
+ location&.include?("/domain/")
115
+ end
116
+ end
117
+ # Adapters are implementations of ports that live in infrastructure
118
+ let(:adapters) do
119
+ classes_in_engine(Rampart::Ports::SecondaryPort).select do |klass|
120
+ location = Object.const_source_location(klass.name)&.first
121
+ location&.include?("/infrastructure/")
122
+ end
123
+ end
124
+ # Controllers (excluding base ApplicationController classes)
125
+ let(:controllers) do
126
+ ObjectSpace.each_object(Class).select do |klass|
127
+ next unless defined?(ActionController::API) && klass < ActionController::API
128
+ # Skip base ApplicationController classes - these are Rails infrastructure, not entrypoints
129
+ next if klass.name&.end_with?("::ApplicationController")
130
+ location = Object.const_source_location(klass.name)&.first
131
+ location && location.start_with?(engine_root) && location.include?("/controllers/")
132
+ end
133
+ end
134
+ let(:queries) { classes_in_engine(Rampart::Application::Query) }
135
+ let(:commands) { classes_in_engine(Rampart::Application::Command) }
136
+
137
+ describe "DI and Wiring Policies" do
138
+ it "services depend only on Ports or Adapters" do
139
+ service_keys = container_class.keys.map(&:to_sym).select { |k| k.to_s.end_with?("_service") }
140
+
141
+ service_keys.each do |key|
142
+ service = container_class.resolve(key)
143
+
144
+ service.instance_variables.each do |ivar|
145
+ dep = service.instance_variable_get(ivar)
146
+ next unless dep
147
+
148
+ next if [String, Integer, TrueClass, FalseClass, Symbol, Array, Hash].any? { |t| dep.is_a?(t) }
149
+
150
+ if defined?(ActiveRecord::Base) && dep.is_a?(ActiveRecord::Base)
151
+ fail "Service #{key} has ActiveRecord dependency in #{ivar}: #{dep.class}"
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ it "controllers only resolve Application Services or allowed Adapters" do
158
+ allowed_keys = container_class.keys.map(&:to_sym).select do |k|
159
+ s = k.to_s
160
+ s.end_with?("_service") || s.end_with?("_query")
161
+ end
162
+
163
+ controller_files = Dir[File.join(engine_root, "app/controllers/**/*.rb")]
164
+ .reject { |f| f.end_with?("AGENTS.md") || f.end_with?("README.md") }
165
+
166
+ controller_files.each do |file|
167
+ expect(file).to only_resolve_allowed_dependencies(allowed_keys)
168
+ end
169
+ end
170
+ end
171
+
172
+ describe "Base Class Contracts" do
173
+ it "aggregates inherit from Rampart::Domain::AggregateRoot" do
174
+ expect(aggregates).to all(inherit_from_rampart_base(Rampart::Domain::AggregateRoot))
175
+ end
176
+
177
+ it "entities inherit from Rampart::Domain::Entity" do
178
+ expect(entities).to all(inherit_from_rampart_base(Rampart::Domain::Entity))
179
+ end
180
+
181
+ it "value objects inherit from Rampart::Domain::ValueObject" do
182
+ expect(value_objects).to all(inherit_from_rampart_base(Rampart::Domain::ValueObject))
183
+ end
184
+
185
+ it "ports inherit from Rampart::Ports::SecondaryPort and are implemented" do
186
+ expect(ports).to all(inherit_from_rampart_base(Rampart::Ports::SecondaryPort))
187
+
188
+ ports.each do |port|
189
+ implementations = classes_in_engine(port)
190
+ expect(implementations).not_to be_empty, "expected #{port} to have at least one implementation"
191
+
192
+ implementations.each do |implementation|
193
+ expect(implementation).to implement_all_abstract_methods(port)
194
+ end
195
+ end
196
+ end
197
+
198
+ it "commands and queries inherit from Rampart base classes" do
199
+ expect(queries).to all(inherit_from_rampart_base(Rampart::Application::Query))
200
+ expect(commands).to all(inherit_from_rampart_base(Rampart::Application::Command))
201
+ end
202
+ end
203
+
204
+ describe "Immutability" do
205
+ it "value objects are immutable" do
206
+ expect(value_objects).to all(be_immutable)
207
+ expect(value_objects).to all(have_no_mutable_instance_variables)
208
+ end
209
+
210
+ it "aggregates are immutable" do
211
+ expect(aggregates).to all(be_immutable)
212
+ end
213
+ end
214
+
215
+ describe "Public API" do
216
+ it "loads the public engine module" do
217
+ module_name = container_class.name.split("::").first
218
+ const = Object.const_get(module_name)
219
+ expect(const).to be_truthy
220
+ end
221
+
222
+ # Note: We rely on Packwerk's enforce_privacy to prevent external access to
223
+ # infrastructure internals. No namespace-based hiding is required—all classes
224
+ # use the flat {Context}::{ClassName} convention.
225
+ end
226
+
227
+ describe "Architecture JSON Sync" do
228
+ def get_names(objects)
229
+ objects.map { |o| o.name.split("::").last }.sort
230
+ end
231
+
232
+ def json_names(path)
233
+ items = architecture_json.dig(*path) || []
234
+ items.map { |i| i.is_a?(Hash) ? i["name"] : i }.sort
235
+ end
236
+
237
+ describe "Aggregates" do
238
+ let(:json_aggregates) { json_names(["layers", "domain", "aggregates"]) }
239
+ let(:code_aggregates) { get_names(aggregates) }
240
+
241
+ it "match between JSON and Code" do
242
+ check_architecture_sync(
243
+ component_type: "Aggregates",
244
+ json_items: json_aggregates,
245
+ code_items: code_aggregates,
246
+ json_path: architecture_json_path,
247
+ add_to_code_hint: "Add these aggregate classes to the codebase",
248
+ add_to_json_hint: "Add these aggregates to #{architecture_json_path}"
249
+ )
250
+ end
251
+ end
252
+
253
+ describe "Events" do
254
+ let(:json_events) { json_names(["layers", "domain", "events"]) }
255
+ let(:code_events) { get_names(events) }
256
+
257
+ it "match between JSON and Code" do
258
+ check_architecture_sync(
259
+ component_type: "Events",
260
+ json_items: json_events,
261
+ code_items: code_events,
262
+ json_path: architecture_json_path,
263
+ add_to_code_hint: "Add these event classes to the codebase",
264
+ add_to_json_hint: "Add these events to #{architecture_json_path}"
265
+ )
266
+ end
267
+ end
268
+
269
+ describe "Ports" do
270
+ let(:json_ports) do
271
+ repos = architecture_json.dig("layers", "domain", "ports", "repositories") || []
272
+ external = (architecture_json.dig("layers", "domain", "ports", "external") || []).map { |p| p["name"] }
273
+ (repos + external).sort
274
+ end
275
+ let(:code_ports) { get_names(ports) }
276
+
277
+ it "match between JSON and Code" do
278
+ check_architecture_sync(
279
+ component_type: "Ports",
280
+ json_items: json_ports,
281
+ code_items: code_ports,
282
+ json_path: architecture_json_path,
283
+ add_to_code_hint: "Add these port interfaces to the domain layer",
284
+ add_to_json_hint: "Add these ports to #{architecture_json_path}"
285
+ )
286
+ end
287
+ end
288
+
289
+ describe "Services" do
290
+ let(:json_services) { json_names(["layers", "application", "services"]) }
291
+ let(:code_services) { get_names(services) }
292
+
293
+ it "match between JSON and Code" do
294
+ check_architecture_sync(
295
+ component_type: "Services",
296
+ json_items: json_services,
297
+ code_items: code_services,
298
+ json_path: architecture_json_path,
299
+ add_to_code_hint: "Add these service classes to the application layer",
300
+ add_to_json_hint: "Add these services to #{architecture_json_path}"
301
+ )
302
+ end
303
+ end
304
+
305
+ describe "Adapters" do
306
+ let(:json_adapters) do
307
+ persistence = (architecture_json.dig("layers", "infrastructure", "adapters", "persistence") || []).map { |a| a["name"] }
308
+ external = (architecture_json.dig("layers", "infrastructure", "adapters", "external") || []).map { |a| a["name"] }
309
+ (persistence + external).sort
310
+ end
311
+ let(:code_adapters) { get_names(adapters) }
312
+
313
+ it "match between JSON and Code" do
314
+ check_architecture_sync(
315
+ component_type: "Adapters",
316
+ json_items: json_adapters,
317
+ code_items: code_adapters,
318
+ json_path: architecture_json_path,
319
+ add_to_code_hint: "Add these adapter implementations to the infrastructure layer",
320
+ add_to_json_hint: "Add these adapters to #{architecture_json_path}"
321
+ )
322
+ end
323
+ end
324
+
325
+ describe "Controllers" do
326
+ let(:json_controllers) do
327
+ (architecture_json.dig("layers", "infrastructure", "entrypoints", "http") || []).map { |c| c["name"] }.sort
328
+ end
329
+ let(:code_controllers) do
330
+ get_names(controllers)
331
+ end
332
+
333
+ it "match between JSON and Code" do
334
+ check_architecture_sync(
335
+ component_type: "Controllers",
336
+ json_items: json_controllers,
337
+ code_items: code_controllers,
338
+ json_path: architecture_json_path,
339
+ add_to_code_hint: "Add these controller classes to app/controllers",
340
+ add_to_json_hint: "Add these controllers to #{architecture_json_path}"
341
+ )
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rampart
4
+ module Testing
5
+ end
6
+ end
7
+
8
+ if defined?(RSpec)
9
+ require_relative "testing/architecture_matchers"
10
+ require_relative "testing/engine_architecture_shared_spec"
11
+
12
+ RSpec.configure do |config|
13
+ config.include Rampart::Testing::ArchitectureMatchers
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ module Rampart
2
+ VERSION = "0.1.1"
3
+ end
4
+
5
+
data/lib/rampart.rb ADDED
@@ -0,0 +1,28 @@
1
+ require "dry-types"
2
+ require "dry-struct"
3
+ require "dry-container"
4
+ require "dry-auto_inject"
5
+ require "dry-monads"
6
+ require "dry-initializer"
7
+
8
+ require_relative "rampart/version"
9
+ require_relative "rampart/support/types"
10
+ require_relative "rampart/support/result"
11
+ require_relative "rampart/support/container"
12
+ require_relative "rampart/domain/entity"
13
+ require_relative "rampart/domain/aggregate_root"
14
+ require_relative "rampart/domain/value_object"
15
+ require_relative "rampart/domain/domain_event"
16
+ require_relative "rampart/domain/domain_exception"
17
+ require_relative "rampart/domain/domain_service"
18
+ require_relative "rampart/application/command"
19
+ require_relative "rampart/application/query"
20
+ require_relative "rampart/application/service"
21
+ require_relative "rampart/application/transaction"
22
+ require_relative "rampart/ports/secondary_port"
23
+ require_relative "rampart/ports/event_bus_port"
24
+ require_relative "rampart/engine_loader"
25
+ require_relative "rampart/testing" if defined?(RSpec)
26
+
27
+ module Rampart
28
+ end
metadata ADDED
@@ -0,0 +1,152 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rampart-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Rampart Team
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-01-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-types
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-struct
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: dry-container
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.11'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.11'
55
+ - !ruby/object:Gem::Dependency
56
+ name: dry-auto_inject
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: dry-monads
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.6'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.6'
83
+ - !ruby/object:Gem::Dependency
84
+ name: dry-initializer
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.1'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.1'
97
+ description: A pure-Ruby framework for building DDD applications with Hexagonal Architecture
98
+ email:
99
+ - team@rampart.dev
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - LICENSE
105
+ - README.md
106
+ - lib/rampart.rb
107
+ - lib/rampart/application/command.rb
108
+ - lib/rampart/application/query.rb
109
+ - lib/rampart/application/service.rb
110
+ - lib/rampart/application/transaction.rb
111
+ - lib/rampart/domain/aggregate_root.rb
112
+ - lib/rampart/domain/domain_event.rb
113
+ - lib/rampart/domain/domain_exception.rb
114
+ - lib/rampart/domain/domain_service.rb
115
+ - lib/rampart/domain/entity.rb
116
+ - lib/rampart/domain/value_object.rb
117
+ - lib/rampart/engine_loader.rb
118
+ - lib/rampart/ports/event_bus_port.rb
119
+ - lib/rampart/ports/secondary_port.rb
120
+ - lib/rampart/support/container.rb
121
+ - lib/rampart/support/result.rb
122
+ - lib/rampart/support/types.rb
123
+ - lib/rampart/testing.rb
124
+ - lib/rampart/testing/architecture_matchers.rb
125
+ - lib/rampart/testing/engine_architecture_shared_spec.rb
126
+ - lib/rampart/version.rb
127
+ homepage: https://github.com/pacaplan/rampart
128
+ licenses:
129
+ - Apache-2.0
130
+ metadata:
131
+ homepage_uri: https://github.com/pacaplan/rampart
132
+ source_code_uri: https://github.com/pacaplan/rampart
133
+ post_install_message:
134
+ rdoc_options: []
135
+ require_paths:
136
+ - lib
137
+ required_ruby_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: 3.3.0
142
+ required_rubygems_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ requirements: []
148
+ rubygems_version: 3.5.22
149
+ signing_key:
150
+ specification_version: 4
151
+ summary: Architecture on Rails
152
+ test_files: []