boxwerk 0.1.0

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,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # Loader handles the creation and initialization of isolated package boxes
5
+ class Loader
6
+ class << self
7
+ # Boot all packages in topological order
8
+ # @param graph [Boxwerk::Graph] The dependency graph
9
+ # @param registry [Boxwerk::Registry] The registry instance
10
+ def boot_all(graph, registry)
11
+ order = graph.topological_order
12
+
13
+ order.each { |package| boot(package, graph, registry) }
14
+ end
15
+
16
+ # Boot a single package
17
+ # @param package [Boxwerk::Package] The package to boot
18
+ # @param graph [Boxwerk::Graph] The dependency graph
19
+ # @param registry [Boxwerk::Registry] The registry instance
20
+ def boot(package, graph, registry)
21
+ return package if package.booted?
22
+
23
+ # Check if RUBY_BOX environment variable is set
24
+ unless ENV['RUBY_BOX'] == '1'
25
+ raise 'Boxwerk requires RUBY_BOX=1 environment variable to enable Ruby::Box support'
26
+ end
27
+
28
+ # Check if we have Ruby::Box support
29
+ unless defined?(Ruby::Box)
30
+ raise 'Boxwerk requires Ruby 4.0+ with Ruby::Box support'
31
+ end
32
+
33
+ # All packages (including root) get their own isolated boxes
34
+ box = Ruby::Box.new
35
+
36
+ # Store the box reference first so it's available during wiring
37
+ package.box = box
38
+
39
+ # Wire imports based on configuration (exports loaded lazily on-demand)
40
+ wire_imports(box, package, graph)
41
+
42
+ # Register in the registry
43
+ registry.register(package.name, package)
44
+
45
+ package
46
+ end
47
+
48
+ private
49
+
50
+ # Load a specific exported constant from package's lib directory using Zeitwerk conventions
51
+ # This enforces strict isolation - only requested exports are loaded, lazily
52
+ # @param package [Boxwerk::Package] The package
53
+ # @param const_name [String] The constant name to load
54
+ def load_export(package, const_name)
55
+ # Skip if already loaded (cached)
56
+ return if package.loaded_exports.key?(const_name)
57
+
58
+ lib_path = File.join(package.path, 'lib')
59
+ return unless File.directory?(lib_path)
60
+
61
+ # Find the file path for this constant using Zeitwerk conventions
62
+ file_path = find_file_for_constant(lib_path, const_name)
63
+
64
+ unless file_path
65
+ raise "Cannot find file for exported constant '#{const_name}' in package '#{package.name}' at #{lib_path}"
66
+ end
67
+
68
+ # Load the file in the package's box
69
+ package.box.require(file_path)
70
+
71
+ # Cache the mapping AFTER successful load
72
+ package.loaded_exports[const_name] = file_path
73
+ end
74
+
75
+ # Load only exported constants from package's lib directory using Zeitwerk conventions
76
+ # @param box [Ruby::Box] The box instance
77
+ # @param package [Boxwerk::Package] The package
78
+ def load_exports(box, package)
79
+ lib_path = File.join(package.path, 'lib')
80
+ return unless File.directory?(lib_path)
81
+
82
+ # Find all files that might contain exported constants and map them to constants
83
+ files_to_load = {} # file_path => [const_name, ...]
84
+
85
+ package.exports.each do |const_name|
86
+ # Skip if already loaded (cached)
87
+ next if package.loaded_exports.key?(const_name)
88
+
89
+ # Find the file path for this constant using Zeitwerk conventions
90
+ file_path = find_file_for_constant(lib_path, const_name)
91
+
92
+ unless file_path
93
+ raise "Cannot find file for exported constant '#{const_name}' in package '#{package.name}' at #{lib_path}"
94
+ end
95
+
96
+ files_to_load[file_path] ||= []
97
+ files_to_load[file_path] << const_name
98
+ end
99
+
100
+ # Load only the discovered files in the box (strict mode - no fallback)
101
+ files_to_load.keys.sort.each do |file|
102
+ box.require(file)
103
+
104
+ # Cache the mapping AFTER successful load
105
+ files_to_load[file].each do |const_name|
106
+ package.loaded_exports[const_name] = file
107
+ end
108
+ end
109
+ end
110
+
111
+ # Find the file that should define a constant using Zeitwerk's conventions
112
+ # Handles nested constants: Foo::Bar can be in lib/foo/bar.rb OR lib/foo.rb
113
+ # @param lib_path [String] The lib directory path
114
+ # @param const_name [String] The constant name (can include ::)
115
+ # @return [String, nil] The file path or nil if not found
116
+ def find_file_for_constant(lib_path, const_name)
117
+ # For nested constants like Foo::Bar, try the nested path first
118
+ if const_name.include?('::')
119
+ # Try conventional nested path: Foo::Bar -> lib/foo/bar.rb
120
+ nested_path = File.join(lib_path, "#{underscore(const_name)}.rb")
121
+ return nested_path if File.exist?(nested_path)
122
+
123
+ # Fall back to parent path: Foo::Bar -> lib/foo.rb
124
+ # The parent file might define the nested constant
125
+ parts = const_name.split('::')
126
+ parent_name = parts[0..-2].join('::')
127
+ parent_file = if parent_name.empty?
128
+ # Top-level nested constant (shouldn't happen, but handle it)
129
+ File.join(lib_path, "#{underscore(parts[-1])}.rb")
130
+ else
131
+ File.join(lib_path, "#{underscore(parent_name)}.rb")
132
+ end
133
+ return parent_file if File.exist?(parent_file)
134
+ else
135
+ # For top-level constants, try conventional path
136
+ conventional_path = File.join(lib_path, "#{underscore(const_name)}.rb")
137
+ return conventional_path if File.exist?(conventional_path)
138
+ end
139
+
140
+ nil
141
+ end
142
+
143
+ # Convert CamelCase to snake_case (Zeitwerk-compatible)
144
+ # @param string [String] The string to convert
145
+ # @return [String] The underscored string
146
+ def underscore(string)
147
+ string
148
+ .gsub(/::/, '/')
149
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
150
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
151
+ .tr('-', '_')
152
+ .downcase
153
+ end
154
+
155
+ # Wire imports according to YAML configuration (list format)
156
+ # @param box [Ruby::Box] The box instance
157
+ # @param package [Boxwerk::Package] The package being booted
158
+ # @param graph [Boxwerk::Graph] The dependency graph
159
+ def wire_imports(box, package, graph)
160
+ package.imports.each do |import_item|
161
+ # Normalize: String or Hash
162
+ if import_item.is_a?(String)
163
+ path = import_item
164
+ config = nil
165
+ else
166
+ path = import_item.keys.first
167
+ config = import_item.values.first
168
+ end
169
+
170
+ dep_name = File.basename(path)
171
+ dependency = graph.packages[dep_name]
172
+
173
+ unless dependency
174
+ raise "Cannot resolve dependency '#{path}' for package '#{package.name}'"
175
+ end
176
+
177
+ unless dependency.booted?
178
+ raise "Dependency '#{dep_name}' not booted yet"
179
+ end
180
+
181
+ wire_import_strategy(box, package, path, config, dependency)
182
+ end
183
+ end
184
+
185
+ # Execute the appropriate wiring strategy based on config type
186
+ # @param box [Ruby::Box] The box instance
187
+ # @param package [Boxwerk::Package] The current package being wired
188
+ # @param path [String] The dependency path
189
+ # @param config [nil, String, Array, Hash] The import configuration
190
+ # @param dependency [Boxwerk::Package] The dependency package
191
+ def wire_import_strategy(box, package, path, config, dependency)
192
+ case config
193
+ when nil
194
+ # Strategy 1: Default Namespace ("packages/billing" -> "Billing")
195
+ name = camelize(File.basename(path))
196
+ create_namespace(package, name, dependency)
197
+ when String
198
+ # Strategy 2: Aliased Namespace ("packages/identity": "Auth")
199
+ create_namespace(package, config, dependency)
200
+ when Array
201
+ # Strategy 3: Selective List (["Log", "Metrics"])
202
+ config.each do |const_name|
203
+ value = get_constant(dependency, const_name)
204
+ set_constant(package, const_name, value)
205
+ end
206
+ when Hash
207
+ # Strategy 4: Selective Rename ({Invoice: "Bill"})
208
+ config.each do |remote_name, local_alias|
209
+ value = get_constant(dependency, remote_name)
210
+ set_constant(package, local_alias, value)
211
+ end
212
+ end
213
+ end
214
+
215
+ # Create a namespace module and populate with all exports
216
+ # Or import directly if single export (optimization)
217
+ # @param package [Boxwerk::Package] The current package
218
+ # @param namespace_name [String] The module name to create
219
+ # @param dependency [Boxwerk::Package] The dependency package
220
+ def create_namespace(package, namespace_name, dependency)
221
+ if dependency.exports.size == 1
222
+ # Single export optimization: import directly
223
+ value = get_constant(dependency, dependency.exports.first)
224
+ set_constant(package, namespace_name, value)
225
+ else
226
+ # Multiple exports: create namespace module
227
+ mod = create_module(package.box, namespace_name)
228
+ dependency.exports.each do |export_name|
229
+ value = get_constant(dependency, export_name)
230
+ mod.const_set(export_name.to_sym, value)
231
+ end
232
+ end
233
+ end
234
+
235
+ # Get a constant from a package's box, loading it lazily if needed
236
+ # @param package [Boxwerk::Package] The package to get the constant from
237
+ # @param name [String] The constant name
238
+ # @return [Object] The constant value
239
+ def get_constant(package, name)
240
+ # Load the export lazily
241
+ load_export(package, name)
242
+
243
+ package.box.const_get(name.to_sym)
244
+ end
245
+
246
+ # Set a constant in a package's box
247
+ # @param package [Boxwerk::Package] The package to set the constant in
248
+ # @param name [String] The constant name
249
+ # @param value [Object] The constant value
250
+ def set_constant(package, name, value)
251
+ package.box.const_set(name.to_sym, value)
252
+ end
253
+
254
+ # Create a module in the box
255
+ def create_module(box, name)
256
+ unless box.const_defined?(name.to_sym, false)
257
+ mod = Module.new
258
+ box.const_set(name.to_sym, mod)
259
+ mod
260
+ else
261
+ box.const_get(name.to_sym)
262
+ end
263
+ end
264
+
265
+ # Set a constant within a module in the box
266
+ def set_const_in_module(box, module_name, const_name, value)
267
+ mod = box.const_get(module_name.to_sym, false)
268
+ mod.const_set(const_name.to_sym, value)
269
+ end
270
+
271
+ # Simple camelization (underscore to CamelCase)
272
+ def camelize(string)
273
+ string.split('_').map(&:capitalize).join
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Boxwerk
6
+ # Represents a package loaded from package.yml
7
+ class Package
8
+ attr_reader :name, :path, :exports, :imports, :box, :loaded_exports
9
+
10
+ def initialize(name, path)
11
+ @name = name
12
+ @path = path
13
+ @box = nil
14
+ @exports = []
15
+ @imports = []
16
+ @loaded_exports = {}
17
+
18
+ load_config
19
+ end
20
+
21
+ def booted?
22
+ !@box.nil?
23
+ end
24
+
25
+ def box=(box_instance)
26
+ @box = box_instance
27
+ end
28
+
29
+ def dependencies
30
+ @imports.map do |item|
31
+ if item.is_a?(String)
32
+ item
33
+ else
34
+ item.keys.first
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def load_config
42
+ config_path = File.join(@path, 'package.yml')
43
+ return unless File.exist?(config_path)
44
+
45
+ config = YAML.load_file(config_path)
46
+
47
+ @exports = config['exports'] || []
48
+ @imports = config['imports'] || []
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # Registry for tracking booted package instances
5
+ # This allows packages to be booted once and reused
6
+ class Registry
7
+ def initialize
8
+ @registry = {}
9
+ end
10
+
11
+ # Register a booted package
12
+ # @param name [Symbol] Package name
13
+ # @param instance [Object] The booted package instance
14
+ def register(name, instance)
15
+ @registry[name] = instance
16
+ end
17
+
18
+ # Retrieve a booted package
19
+ # @param name [Symbol] Package name
20
+ # @return [Object, nil] The package instance or nil
21
+ def get(name)
22
+ @registry[name]
23
+ end
24
+
25
+ # Check if a package is registered
26
+ # @param name [Symbol] Package name
27
+ # @return [Boolean]
28
+ def registered?(name)
29
+ @registry.key?(name)
30
+ end
31
+
32
+ # Clear the registry (useful for testing)
33
+ def clear!
34
+ @registry = {}
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # Setup orchestrates the Boxwerk boot process
5
+ module Setup
6
+ class << self
7
+ # Main entry point for setup
8
+ # @param start_dir [String] Directory to start searching for package.yml
9
+ # @return [Boxwerk::Graph] The loaded and validated graph
10
+ def run!(start_dir: Dir.pwd)
11
+ # Find the root package.yml
12
+ root_path = find_package_yml(start_dir)
13
+ unless root_path
14
+ raise 'Cannot find package.yml in current directory or ancestors'
15
+ end
16
+
17
+ # Build and validate dependency graph (happens automatically in constructor)
18
+ graph = Boxwerk::Graph.new(root_path)
19
+
20
+ # Create a registry instance for tracking booted packages
21
+ registry = Boxwerk::Registry.new
22
+
23
+ # Boot all packages in topological order (all in isolated boxes)
24
+ Boxwerk::Loader.boot_all(graph, registry)
25
+
26
+ # Store graph for introspection
27
+ @graph = graph
28
+ @booted = true
29
+
30
+ graph
31
+ end
32
+
33
+ # Returns the loaded graph (for introspection)
34
+ # @return [Boxwerk::Graph, nil]
35
+ def graph
36
+ @graph
37
+ end
38
+
39
+ # Check if Boxwerk has been booted
40
+ # @return [Boolean]
41
+ def booted?
42
+ @booted || false
43
+ end
44
+
45
+ # Reset the setup state (useful for testing)
46
+ def reset!
47
+ @graph = nil
48
+ @booted = false
49
+ end
50
+
51
+ private
52
+
53
+ # Find package.yml by searching up the directory tree
54
+ # @param start_dir [String] Directory to start searching from
55
+ # @return [String, nil] Path to directory containing package.yml, or nil
56
+ def find_package_yml(start_dir)
57
+ current = File.expand_path(start_dir)
58
+ loop do
59
+ package_yml = File.join(current, 'package.yml')
60
+ return current if File.exist?(package_yml)
61
+
62
+ parent = File.dirname(current)
63
+ break if parent == current # reached filesystem root
64
+
65
+ current = parent
66
+ end
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ VERSION = '0.1.0'
5
+ end
data/lib/boxwerk.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'boxwerk/cli'
4
+ require_relative 'boxwerk/graph'
5
+ require_relative 'boxwerk/loader'
6
+ require_relative 'boxwerk/package'
7
+ require_relative 'boxwerk/registry'
8
+ require_relative 'boxwerk/setup'
9
+ require_relative 'boxwerk/version'
10
+
11
+ module Boxwerk
12
+ class Error < StandardError
13
+ end
14
+ end
data/sig/boxwerk.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Boxwerk
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: boxwerk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - David Cristofaro
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: irb
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.16'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.16'
26
+ description: Boxwerk provides strict package isolation for Ruby applications using
27
+ Ruby 4.0+ Box. It is used to organize code into packages with explicit dependency
28
+ graphs and strict access to constants between packages. It is inspired by Packwerk,
29
+ a static package system.
30
+ email:
31
+ - david@dtcristo.com
32
+ executables:
33
+ - boxwerk
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - example/Gemfile
42
+ - example/Gemfile.lock
43
+ - example/README.md
44
+ - example/app.rb
45
+ - example/package.yml
46
+ - example/packages/finance/lib/invoice.rb
47
+ - example/packages/finance/lib/tax_calculator.rb
48
+ - example/packages/finance/package.yml
49
+ - example/packages/util/lib/calculator.rb
50
+ - example/packages/util/lib/geometry.rb
51
+ - example/packages/util/package.yml
52
+ - exe/boxwerk
53
+ - lib/boxwerk.rb
54
+ - lib/boxwerk/cli.rb
55
+ - lib/boxwerk/graph.rb
56
+ - lib/boxwerk/loader.rb
57
+ - lib/boxwerk/package.rb
58
+ - lib/boxwerk/registry.rb
59
+ - lib/boxwerk/setup.rb
60
+ - lib/boxwerk/version.rb
61
+ - sig/boxwerk.rbs
62
+ homepage: https://github.com/dtcristo/boxwerk
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ allowed_push_host: https://rubygems.org
67
+ homepage_uri: https://github.com/dtcristo/boxwerk
68
+ source_code_uri: https://github.com/dtcristo/boxwerk
69
+ changelog_uri: https://github.com/dtcristo/boxwerk/blob/main/CHANGELOG.md
70
+ rdoc_options: []
71
+ require_paths:
72
+ - lib
73
+ required_ruby_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: 4.0.0
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ requirements: []
84
+ rubygems_version: 4.0.3
85
+ specification_version: 4
86
+ summary: Ruby package system with Box-powered constant isolation
87
+ test_files: []