boxwerk 0.1.0 → 0.2.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.
data/lib/boxwerk/graph.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Boxwerk
4
- # Builds and validates the package dependency graph
4
+ # Graph builds a directed acyclic graph (DAG) of package dependencies.
5
+ # Validates no circular dependencies and provides topological ordering for boot sequence.
5
6
  class Graph
6
7
  attr_reader :packages, :root
7
8
 
@@ -10,102 +11,43 @@ module Boxwerk
10
11
  @packages = {}
11
12
  @root = load_package('root', root_path)
12
13
  resolve_dependencies(@root, [])
13
- validate!
14
14
  end
15
15
 
16
- # Returns packages in topological order (leaves first)
17
16
  def topological_order
18
17
  visited = {}
19
18
  order = []
20
-
21
- @packages.each_value { |package| visit(package, visited, order, []) }
22
-
19
+ @packages.each_value { |pkg| visit(pkg, visited, order, []) }
23
20
  order
24
21
  end
25
22
 
26
23
  private
27
24
 
28
- # Validate that the graph is acyclic
29
- def validate!
30
- visited = {}
31
- rec_stack = {}
32
-
33
- @packages.each_value do |package|
34
- if has_cycle?(package, visited, rec_stack, [])
35
- raise 'Circular dependency detected in package graph'
36
- end
37
- end
38
-
39
- true
40
- end
41
-
42
25
  def load_package(name, path)
43
26
  return @packages[name] if @packages[name]
44
27
 
45
- package = Package.new(name, path)
46
- @packages[name] = package
47
- package
28
+ @packages[name] = Package.new(name, path)
48
29
  end
49
30
 
50
31
  def resolve_dependencies(package, path)
51
- if path.include?(package.name)
52
- cycle = (path + [package.name]).join(' -> ')
53
- raise "Circular dependency detected: #{cycle}"
54
- end
32
+ raise "Circular dependency: #{(path + [package.name]).join(' -> ')}" if path.include?(package.name)
55
33
 
56
34
  package.dependencies.each do |dep_path|
57
- dep_name = File.basename(dep_path)
58
35
  full_path = File.join(@root_path, dep_path)
36
+ raise "Package not found: #{dep_path}" unless File.directory?(full_path)
59
37
 
60
- unless File.directory?(full_path)
61
- raise "Package not found: #{dep_path} (expected at #{full_path})"
62
- end
63
-
64
- dep_package = load_package(dep_name, full_path)
65
- resolve_dependencies(dep_package, path + [package.name])
38
+ resolve_dependencies(load_package(File.basename(dep_path), full_path), path + [package.name])
66
39
  end
67
40
  end
68
41
 
69
- # DFS for topological sort
70
42
  def visit(package, visited, order, path)
71
43
  return if visited[package.name]
72
44
 
73
- if path.include?(package.name)
74
- raise "Circular dependency: #{(path + [package.name]).join(' -> ')}"
75
- end
76
-
77
45
  visited[package.name] = true
78
-
79
46
  package.dependencies.each do |dep_path|
80
- dep_name = File.basename(dep_path)
81
- dep_package = @packages[dep_name]
47
+ dep_package = @packages[File.basename(dep_path)]
82
48
  visit(dep_package, visited, order, path + [package.name]) if dep_package
83
49
  end
84
-
85
50
  order << package
86
51
  end
87
-
88
- # Cycle detection
89
- def has_cycle?(package, visited, rec_stack, path)
90
- return false if visited[package.name]
91
-
92
- return true if rec_stack[package.name]
93
-
94
- visited[package.name] = true
95
- rec_stack[package.name] = true
96
-
97
- package.dependencies.each do |dep_path|
98
- dep_name = File.basename(dep_path)
99
- dep_package = @packages[dep_name]
100
-
101
- if dep_package &&
102
- has_cycle?(dep_package, visited, rec_stack, path + [package.name])
103
- return true
104
- end
105
- end
106
-
107
- rec_stack[package.name] = false
108
- false
109
- end
110
52
  end
111
53
  end
@@ -1,45 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Boxwerk
4
- # Loader handles the creation and initialization of isolated package boxes
4
+ # Loader creates isolated Ruby::Box instances for packages and wires imports.
5
+ # Lazily loads exports using Zeitwerk naming conventions and injects constants.
5
6
  class Loader
6
7
  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
8
  def boot_all(graph, registry)
11
9
  order = graph.topological_order
12
10
 
13
11
  order.each { |package| boot(package, graph, registry) }
14
12
  end
15
13
 
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
14
  def boot(package, graph, registry)
21
15
  return package if package.booted?
22
16
 
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
17
+ package.box = Ruby::Box.new
18
+ wire_imports(package.box, package, graph)
43
19
  registry.register(package.name, package)
44
20
 
45
21
  package
@@ -47,10 +23,6 @@ module Boxwerk
47
23
 
48
24
  private
49
25
 
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
26
  def load_export(package, const_name)
55
27
  # Skip if already loaded (cached)
56
28
  return if package.loaded_exports.key?(const_name)
@@ -58,91 +30,33 @@ module Boxwerk
58
30
  lib_path = File.join(package.path, 'lib')
59
31
  return unless File.directory?(lib_path)
60
32
 
61
- # Find the file path for this constant using Zeitwerk conventions
62
33
  file_path = find_file_for_constant(lib_path, const_name)
63
-
64
34
  unless file_path
65
- raise "Cannot find file for exported constant '#{const_name}' in package '#{package.name}' at #{lib_path}"
35
+ raise "Cannot find file for exported constant '#{const_name}' in package '#{package.name}'"
66
36
  end
67
37
 
68
- # Load the file in the package's box
69
38
  package.box.require(file_path)
70
-
71
- # Cache the mapping AFTER successful load
72
39
  package.loaded_exports[const_name] = file_path
73
40
  end
74
41
 
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
42
  def find_file_for_constant(lib_path, const_name)
117
- # For nested constants like Foo::Bar, try the nested path first
118
43
  if const_name.include?('::')
119
- # Try conventional nested path: Foo::Bar -> lib/foo/bar.rb
120
44
  nested_path = File.join(lib_path, "#{underscore(const_name)}.rb")
121
45
  return nested_path if File.exist?(nested_path)
122
46
 
123
- # Fall back to parent path: Foo::Bar -> lib/foo.rb
124
- # The parent file might define the nested constant
125
47
  parts = const_name.split('::')
126
48
  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
49
+ parent_file = File.join(lib_path, "#{underscore(parent_name)}.rb")
133
50
  return parent_file if File.exist?(parent_file)
134
51
  else
135
- # For top-level constants, try conventional path
136
- conventional_path = File.join(lib_path, "#{underscore(const_name)}.rb")
52
+ conventional_path =
53
+ File.join(lib_path, "#{underscore(const_name)}.rb")
137
54
  return conventional_path if File.exist?(conventional_path)
138
55
  end
139
56
 
140
57
  nil
141
58
  end
142
59
 
143
- # Convert CamelCase to snake_case (Zeitwerk-compatible)
144
- # @param string [String] The string to convert
145
- # @return [String] The underscored string
146
60
  def underscore(string)
147
61
  string
148
62
  .gsub(/::/, '/')
@@ -152,28 +66,22 @@ module Boxwerk
152
66
  .downcase
153
67
  end
154
68
 
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
69
  def wire_imports(box, package, graph)
160
70
  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
-
71
+ path, config =
72
+ (
73
+ if import_item.is_a?(String)
74
+ [import_item, nil]
75
+ else
76
+ [import_item.keys.first, import_item.values.first]
77
+ end
78
+ )
170
79
  dep_name = File.basename(path)
171
80
  dependency = graph.packages[dep_name]
172
81
 
173
82
  unless dependency
174
83
  raise "Cannot resolve dependency '#{path}' for package '#{package.name}'"
175
84
  end
176
-
177
85
  unless dependency.booted?
178
86
  raise "Dependency '#{dep_name}' not booted yet"
179
87
  end
@@ -182,93 +90,57 @@ module Boxwerk
182
90
  end
183
91
  end
184
92
 
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
93
  def wire_import_strategy(box, package, path, config, dependency)
192
94
  case config
193
95
  when nil
194
- # Strategy 1: Default Namespace ("packages/billing" -> "Billing")
195
- name = camelize(File.basename(path))
196
- create_namespace(package, name, dependency)
96
+ create_namespace(package, camelize(File.basename(path)), dependency)
197
97
  when String
198
- # Strategy 2: Aliased Namespace ("packages/identity": "Auth")
199
98
  create_namespace(package, config, dependency)
200
99
  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)
100
+ config.each do |name|
101
+ set_constant(package, name, get_constant(dependency, name))
205
102
  end
206
103
  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)
104
+ config.each do |remote, local|
105
+ set_constant(package, local, get_constant(dependency, remote))
211
106
  end
212
107
  end
213
108
  end
214
109
 
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
110
  def create_namespace(package, namespace_name, dependency)
221
111
  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)
112
+ set_constant(
113
+ package,
114
+ namespace_name,
115
+ get_constant(dependency, dependency.exports.first),
116
+ )
225
117
  else
226
- # Multiple exports: create namespace module
227
118
  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)
119
+ dependency.exports.each do |name|
120
+ mod.const_set(name.to_sym, get_constant(dependency, name))
231
121
  end
232
122
  end
233
123
  end
234
124
 
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
125
  def get_constant(package, name)
240
- # Load the export lazily
241
126
  load_export(package, name)
242
-
243
127
  package.box.const_get(name.to_sym)
244
128
  end
245
129
 
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
130
  def set_constant(package, name, value)
251
131
  package.box.const_set(name.to_sym, value)
252
132
  end
253
133
 
254
- # Create a module in the box
255
134
  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)
135
+ if box.const_defined?(name.to_sym, false)
136
+ return box.const_get(name.to_sym)
262
137
  end
263
- end
264
138
 
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)
139
+ mod = Module.new
140
+ box.const_set(name.to_sym, mod)
141
+ mod
269
142
  end
270
143
 
271
- # Simple camelization (underscore to CamelCase)
272
144
  def camelize(string)
273
145
  string.split('_').map(&:capitalize).join
274
146
  end
@@ -3,9 +3,11 @@
3
3
  require 'yaml'
4
4
 
5
5
  module Boxwerk
6
- # Represents a package loaded from package.yml
6
+ # Package represents a single package loaded from package.yml.
7
+ # Tracks exports, imports, dependencies, and the isolated Ruby::Box instance.
7
8
  class Package
8
- attr_reader :name, :path, :exports, :imports, :box, :loaded_exports
9
+ attr_reader :name, :path, :exports, :imports, :loaded_exports
10
+ attr_accessor :box
9
11
 
10
12
  def initialize(name, path)
11
13
  @name = name
@@ -22,18 +24,8 @@ module Boxwerk
22
24
  !@box.nil?
23
25
  end
24
26
 
25
- def box=(box_instance)
26
- @box = box_instance
27
- end
28
-
29
27
  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
28
+ @imports.map { |item| item.is_a?(String) ? item : item.keys.first }
37
29
  end
38
30
 
39
31
  private
@@ -43,7 +35,6 @@ module Boxwerk
43
35
  return unless File.exist?(config_path)
44
36
 
45
37
  config = YAML.load_file(config_path)
46
-
47
38
  @exports = config['exports'] || []
48
39
  @imports = config['imports'] || []
49
40
  end
@@ -1,35 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Boxwerk
4
- # Registry for tracking booted package instances
5
- # This allows packages to be booted once and reused
4
+ # Registry tracks booted package instances to ensure each package boots only once.
6
5
  class Registry
7
6
  def initialize
8
7
  @registry = {}
9
8
  end
10
9
 
11
- # Register a booted package
12
- # @param name [Symbol] Package name
13
- # @param instance [Object] The booted package instance
14
10
  def register(name, instance)
15
11
  @registry[name] = instance
16
12
  end
17
13
 
18
- # Retrieve a booted package
19
- # @param name [Symbol] Package name
20
- # @return [Object, nil] The package instance or nil
21
14
  def get(name)
22
15
  @registry[name]
23
16
  end
24
17
 
25
- # Check if a package is registered
26
- # @param name [Symbol] Package name
27
- # @return [Boolean]
28
18
  def registered?(name)
29
19
  @registry.key?(name)
30
20
  end
31
21
 
32
- # Clear the registry (useful for testing)
33
22
  def clear!
34
23
  @registry = {}
35
24
  end
data/lib/boxwerk/setup.rb CHANGED
@@ -1,48 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Boxwerk
4
- # Setup orchestrates the Boxwerk boot process
4
+ # Setup finds the root package, builds the dependency graph, and boots all packages.
5
+ # Orchestrates the entire Boxwerk initialization process.
5
6
  module Setup
6
7
  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
8
  def run!(start_dir: Dir.pwd)
11
- # Find the root package.yml
12
9
  root_path = find_package_yml(start_dir)
13
- unless root_path
14
- raise 'Cannot find package.yml in current directory or ancestors'
15
- end
10
+ raise 'Cannot find package.yml in current directory or ancestors' unless root_path
16
11
 
17
- # Build and validate dependency graph (happens automatically in constructor)
18
12
  graph = Boxwerk::Graph.new(root_path)
19
-
20
- # Create a registry instance for tracking booted packages
21
13
  registry = Boxwerk::Registry.new
22
-
23
- # Boot all packages in topological order (all in isolated boxes)
24
14
  Boxwerk::Loader.boot_all(graph, registry)
25
15
 
26
- # Store graph for introspection
27
16
  @graph = graph
28
17
  @booted = true
29
-
30
18
  graph
31
19
  end
32
20
 
33
- # Returns the loaded graph (for introspection)
34
- # @return [Boxwerk::Graph, nil]
35
21
  def graph
36
22
  @graph
37
23
  end
38
24
 
39
- # Check if Boxwerk has been booted
40
- # @return [Boolean]
41
25
  def booted?
42
26
  @booted || false
43
27
  end
44
28
 
45
- # Reset the setup state (useful for testing)
46
29
  def reset!
47
30
  @graph = nil
48
31
  @booted = false
@@ -50,17 +33,13 @@ module Boxwerk
50
33
 
51
34
  private
52
35
 
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
36
  def find_package_yml(start_dir)
57
37
  current = File.expand_path(start_dir)
58
38
  loop do
59
- package_yml = File.join(current, 'package.yml')
60
- return current if File.exist?(package_yml)
39
+ return current if File.exist?(File.join(current, 'package.yml'))
61
40
 
62
41
  parent = File.dirname(current)
63
- break if parent == current # reached filesystem root
42
+ break if parent == current
64
43
 
65
44
  current = parent
66
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Boxwerk
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/boxwerk.rb CHANGED
@@ -7,8 +7,3 @@ require_relative 'boxwerk/package'
7
7
  require_relative 'boxwerk/registry'
8
8
  require_relative 'boxwerk/setup'
9
9
  require_relative 'boxwerk/version'
10
-
11
- module Boxwerk
12
- class Error < StandardError
13
- end
14
- end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: boxwerk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cristofaro
@@ -23,10 +23,9 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
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.
26
+ description: Boxwerk is an experimental Ruby package system with Box-powered constant
27
+ isolation. It is used at runtime to organize code into packages with explicit dependencies
28
+ and strict constant access using Ruby::Box. Inspired by Packwerk.
30
29
  email:
31
30
  - david@dtcristo.com
32
31
  executables:
@@ -38,17 +37,6 @@ files:
38
37
  - LICENSE.txt
39
38
  - README.md
40
39
  - 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
40
  - exe/boxwerk
53
41
  - lib/boxwerk.rb
54
42
  - lib/boxwerk/cli.rb
@@ -58,7 +46,6 @@ files:
58
46
  - lib/boxwerk/registry.rb
59
47
  - lib/boxwerk/setup.rb
60
48
  - lib/boxwerk/version.rb
61
- - sig/boxwerk.rbs
62
49
  homepage: https://github.com/dtcristo/boxwerk
63
50
  licenses:
64
51
  - MIT
data/example/Gemfile DELETED
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source 'https://rubygems.org'
4
-
5
- gem 'boxwerk', path: '..'
6
- gem 'money'