boxwerk 0.1.0 โ†’ 0.3.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/README.md CHANGED
@@ -1,376 +1,102 @@
1
- # Boxwerk
1
+ <div align="center">
2
+ <h1>
3
+ ๐Ÿ“ฆ Boxwerk
4
+ </h1>
5
+ </div>
2
6
 
3
- Boxwerk is a runtime package system for Ruby with strict isolation of constants using Ruby 4.0's [`Ruby::Box`](https://docs.ruby-lang.org/en/master/Ruby/Box.html). It is used to organize code into packages with explicit dependency graphs and strict access to constants between packages. It is inspired by [Packwerk](https://github.com/Shopify/packwerk), a static package system.
7
+ Boxwerk is a tool for creating modular Ruby and Rails applications. It enables you to organize code into packages of Ruby files with clear boundaries and explicit dependencies. Boxwerk is heavily inspired by [Packwerk](https://github.com/Shopify/packwerk) but provides more robust enforcement at runtime using [`Ruby::Box`](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html), ensuring that only public constants from direct dependencies are accessible. Violations raise `NameError`, turning architectural rules into runtime guarantees.
4
8
 
5
- ## Features
9
+ As your application grows, Boxwerk helps prevent accidental coupling, enforces modularity, and makes it easier to understand and modify code without breaking other parts of the system.
6
10
 
7
- - **Strict Isolation**: Each package runs in its own `Ruby::Box`, preventing constants from leaking without explicit imports or exports.
8
- - **Explicit Dependencies**: Dependencies are declared in `package.yml` files, forming a validated DAG.
9
- - **Ergonomic Imports**: Flexible import strategies (namespaced, aliased, selective, renamed).
11
+ **[Usage Guide](USAGE.md)** ยท **[API Documentation](https://dtcristo.github.io/boxwerk/)** ยท **[Changelog](CHANGELOG.md)**
10
12
 
11
- ## Limtations
13
+ ## Features
12
14
 
13
- - There is no isolation of gems.
14
- - Gems are required to be eager loaded in the root box to be accessible in packages.
15
- - No support for reloading of constants.
16
- - Exported constants must follow Zeitwerk naming conventions for their source location.
15
+ - Boxwerk reads standard Packwerk `package.yml` files, supporting both dependency and privacy enforcement. Packwerk itself is not required.
16
+ - Packages in a Boxwerk application share a set of global gems but may also define package-local ones. Multiple packages can depend on different versions of the same gem.
17
+ - `Ruby::Box` provides monkey patch isolation between packages.
18
+ - Boxwerk uses [Zeitwerk](https://github.com/fxn/zeitwerk) to automatically load constants in packages with [conventional file structure](https://github.com/fxn/zeitwerk#file-structure) although manual loading is also supported.
17
19
 
18
- ## Requirements
20
+ ## Goals
19
21
 
20
- - Ruby 4.0+ with [`Ruby::Box`](https://docs.ruby-lang.org/en/master/Ruby/Box.html) support
21
- - `RUBY_BOX=1` environment variable must be set at process startup
22
+ - **Enforce boundaries at runtime.** `Ruby::Box` turns architectural rules into runtime guarantees. Undeclared dependencies and privacy violations raise `NameError`.
23
+ - **Enable gradual modularization.** Add `package.yml` files around existing code and declare dependencies incrementally.
24
+ - **Feel Ruby-native.** Integrates with Bundler, `gems.rb`/`Gemfile`, and standard Ruby tools. `boxwerk exec rake test` feels like any other Ruby command.
22
25
 
23
- ## Quick Start
26
+ ## Ruby::Box
24
27
 
25
- ### 1. Create a Package Structure
28
+ [`Ruby::Box`](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html) (Ruby 4.0+) provides in-process isolation of classes, modules, and constants. Each box has its own top-level `Object`, isolated `$LOAD_PATH` and `$LOADED_FEATURES`, and independent monkey patches. Boxwerk creates one box per package and wires cross-package constant resolution through `const_missing`.
26
29
 
27
- ```
28
- my_app/
29
- โ”œโ”€โ”€ Gemfile # Your gem dependencies
30
- โ”œโ”€โ”€ package.yml # Root package
31
- โ”œโ”€โ”€ app.rb # Your application entrypoint
32
- โ””โ”€โ”€ packages/
33
- โ””โ”€โ”€ billing/
34
- โ”œโ”€โ”€ package.yml # Package manifest
35
- โ””โ”€โ”€ lib/
36
- โ””โ”€โ”€ invoice.rb # Package code
37
- ```
30
+ Set `RUBY_BOX=1` before starting Ruby. See the [official documentation](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html) for details. See [ARCHITECTURE.md](ARCHITECTURE.md) for how Boxwerk uses `Ruby::Box` internally.
38
31
 
39
- ### 2. Define Your Gemfile
32
+ ## Quick Start
40
33
 
41
- **`Gemfile`:**
42
- ```ruby
43
- source 'https://rubygems.org'
34
+ Create packages with `package.yml` files:
44
35
 
45
- gem 'boxwerk'
46
- gem 'money' # Example: gems are auto-required and globally accessible
47
36
  ```
48
-
49
- ### 3. Define Packages
50
-
51
- **Root `package.yml`:**
52
- ```yaml
53
- imports:
54
- - packages/finance # Will define a `Finance` module to hold finance package exports
37
+ my_app/
38
+ โ”œโ”€โ”€ package.yml
39
+ โ”œโ”€โ”€ main.rb
40
+ โ””โ”€โ”€ packs/
41
+ โ”œโ”€โ”€ foo/
42
+ โ”‚ โ”œโ”€โ”€ package.yml
43
+ โ”‚ โ””โ”€โ”€ lib/foo.rb
44
+ โ””โ”€โ”€ bar/
45
+ โ”œโ”€โ”€ package.yml
46
+ โ””โ”€โ”€ lib/bar.rb
55
47
  ```
56
48
 
57
- **`packages/finance/package.yml`:**
58
49
  ```yaml
59
- exports:
60
- - Invoice
61
- - TaxCalculator
62
- ```
63
-
64
- **`packages/finance/lib/invoice.rb`:**
65
- ```ruby
66
- class Invoice
67
- def initialize(amount_cents)
68
- # Money gem is accessible because it's in the Gemfile
69
- @amount = Money.new(amount_cents, 'USD')
70
- end
71
-
72
- def total
73
- @amount
74
- end
75
- end
76
- ```
77
-
78
- **`packages/finance/lib/tax_calculator.rb`:**
79
- ```ruby
80
- class TaxCalculator
81
- # ...
82
- end
83
- ```
84
-
85
- ### 4. Use in Your Application
86
-
87
- **`app.rb`:**
88
- ```ruby
89
- # No requires needed - imports are wired by Boxwerk
90
- invoice = Finance::Invoice.new(10_000)
91
- puts invoice.total # => #<Money fractional:10000 currency:USD>
92
- ```
93
-
94
- ### 5. Run Your Application
95
-
96
- ```bash
97
- RUBY_BOX=1 boxwerk run app.rb
98
- ```
99
-
100
- Boxwerk automatically:
101
- 1. Sets up Bundler
102
- 2. Requires all gems from your Gemfile in the root box
103
- 3. Loads and wires all packages
104
- 4. Executes your script in the root package context
105
-
106
- ## Usage
107
-
108
- ### Running Scripts
109
-
110
- Execute a Ruby script in the root package context:
111
-
112
- ```bash
113
- boxwerk run script.rb [args...]
50
+ # package.yml (root)
51
+ enforce_dependencies: true
52
+ dependencies:
53
+ - packs/foo
54
+ - packs/bar
114
55
  ```
115
56
 
116
- The script has access to:
117
- - All gems from your Gemfile (automatically required)
118
- - All imports defined in the root `package.yml`
119
-
120
- ### Interactive Console
121
-
122
- TODO: This feature is currenly broken and will run IRB from the root box, not the root package as desired.
123
-
124
- Start an IRB session in the root package context:
57
+ Install and run:
125
58
 
126
59
  ```bash
127
- boxwerk console [irb-args...]
60
+ gem install boxwerk
61
+ RUBY_BOX=1 boxwerk run main.rb
128
62
  ```
129
63
 
130
- All imports and gems are available for interactive exploration.
64
+ No Bundler or Gemfile required for basic usage. To use global or per-package gems, see [USAGE.md](USAGE.md).
131
65
 
132
- ### Help
66
+ ## CLI
133
67
 
134
- ```bash
135
- boxwerk help
136
68
  ```
137
-
138
- ## Package Configuration
139
-
140
- A `package.yml` defines what a package exports and imports:
141
-
142
- ```yaml
143
- exports:
144
- - PublicClass
145
- - PublicModule
146
-
147
- imports:
148
- - packages/dependency1
149
- - packages/dependency2: Alias
69
+ boxwerk run <script.rb> Run a Ruby script in a package box
70
+ boxwerk exec <command> [args...] Execute a command in the boxed environment
71
+ boxwerk console Interactive console in a package box
72
+ boxwerk info Show package structure and dependencies
73
+ boxwerk install Install gems for all packages
150
74
  ```
151
75
 
152
- ### Exports
153
-
154
- Constants that should be visible to packages that import this one.
155
-
156
- ### Imports
157
-
158
- Dependencies this package needs. **Note**: Dependencies are NOT transitive. If package A imports B, and B imports C, then A cannot access C unless it explicitly imports it.
159
-
160
- ## Import Strategies
161
-
162
- Boxwerk supports four import strategies in `package.yml`:
76
+ Options: `--package <package>`, `--all`, `--global`. See [USAGE.md](USAGE.md) for details.
163
77
 
164
- ### 1. Default Namespace
78
+ ## Limitations
165
79
 
166
- Import all exports under a module named after the package:
167
-
168
- ```yaml
169
- imports:
170
- - packages/finance
171
- ```
172
-
173
- Result: `Finance::Invoice`, `Finance::TaxCalculator`
174
-
175
- ### 2. Aliased Namespace
176
-
177
- Import under a custom module name:
178
-
179
- ```yaml
180
- imports:
181
- - packages/finance: Billing
182
- ```
183
-
184
- Result: `Billing::Invoice`, `Billing::TaxCalculator`
185
-
186
- **Single Export Optimization**: If a package exports only one constant, it's imported directly (not wrapped in a module):
187
-
188
- ```yaml
189
- # util exports only Calculator
190
- imports:
191
- - packages/util: Calc
192
- ```
80
+ - `Ruby::Box` is experimental in Ruby 4.0
81
+ - No constant reloading (restart required for code changes)
82
+ - IRB autocomplete disabled in console
193
83
 
194
- Result: `Calc` (not `Calc::Calculator`)
84
+ See [TODO.md](TODO.md) for plans to address these and other planned features.
195
85
 
196
- ### 3. Selective Import
86
+ ## Examples
197
87
 
198
- Import specific constants directly:
199
-
200
- ```yaml
201
- imports:
202
- - packages/finance:
203
- - Invoice
204
- - TaxCalculator
205
- ```
206
-
207
- Result: `Invoice`, `TaxCalculator` (no namespace)
208
-
209
- ### 4. Selective Rename
210
-
211
- Import specific constants with custom names:
212
-
213
- ```yaml
214
- imports:
215
- - packages/finance:
216
- Invoice: Bill
217
- TaxCalculator: Calculator
218
- ```
219
-
220
- Result: `Bill`, `Calculator`
221
-
222
- ## Gems and Packages
223
-
224
- ### How Gems Work in Boxwerk
225
-
226
- When you run `boxwerk`, all gems in your `Gemfile` are:
227
- 1. Automatically loaded via Bundler in the root box
228
- 2. Accessible globally in all package boxes (gems are not isolated)
229
-
230
- This means:
231
- - You can use any gem from your Gemfile in any package.
232
- - Gems don't need to be declared in `package.yml`.
233
- - You do not `require` gems manually.
234
-
235
- ### Isolation Model
236
-
237
- - **Root Box**: The box where Ruby bootstraps and all builtin classes/modules are defined. In Boxwerk, the root box performs all setup operations (Bundler setup, gem loading, dependency graph building, package box creation, and import wiring).
238
- - **Main Box**: The first user box created automatically by Ruby (copied from root box). In Boxwerk, it only runs the `exe/boxwerk` executable file, which then calls into the root box to execute the setup. The main box has no other purpose.
239
- - **Package Boxes**: Each package (including root package) runs in its own isolated `Ruby::Box` (created by copying from root box after gems are loaded).
240
- - **Box Inheritance**: All boxes are created via copy-on-write from the root box, inheriting builtin classes and loaded gems.
241
- - **Gems are Global**: All gems from Gemfile are accessible in all boxes (loaded in root box before package boxes are created).
242
- - **Package Exports are Isolated**: Only explicit imports from packages are accessible.
243
- - **No Transitive Access**: Packages can only see their explicit imports.
244
-
245
- For more details on how Ruby::Box works, see the [official Ruby::Box documentation](https://docs.ruby-lang.org/en/master/Ruby/Box.html).
246
-
247
- ## Known Issues
248
-
249
- These issues are related to the current state of Ruby::Box in Ruby 4.0+. See the [Ruby::Box documentation](https://docs.ruby-lang.org/en/master/Ruby/Box.html) for known issues with the feature itself.
250
-
251
- ### Gem Requiring in Boxes
252
-
253
- Requiring any gem from within a box (after boot) currently crashes the Ruby VM. This is likely an issue with Ruby::Box itself. As a workaround, Boxwerk automatically requires all gems from the Gemfile in the root box before creating package boxes, so gems are already loaded and accessible everywhere.
254
-
255
- ### Console Context
256
-
257
- The console does not correctly run in the root package boxโ€”it runs in the context of the root box instead. It should run in the root package box. However, if we attempt to `require 'irb'` in the root package box, the Ruby VM crashes due to the gem requiring issue described above.
258
-
259
- ### IRB Autocomplete
260
-
261
- Autocomplete is disabled for the console/IRB by default. When enabled, the Ruby VM crashes as soon as any key is pressed. This appears to be an issue with Ruby::Box and IRB's autocomplete feature interacting poorly.
262
-
263
- ## Architecture
264
-
265
- ### Boot Process
266
-
267
- 1. Setup Bundler and require all gems in the root box
268
- 2. Find root `package.yml` (searches up from current directory)
269
- 3. Build dependency graph from package manifests
270
- 4. Validate dependency graph (no circular dependencies)
271
- 5. Boot packages in topological order
272
- 6. Wire imports into each package box
273
- 7. Execute command in root package context
274
-
275
- ### Internal Components
276
-
277
- Boxwerk consists of several internal components that work together to provide package isolation:
278
-
279
- #### `Boxwerk::CLI`
280
-
281
- The command-line interface handler that:
282
- - Parses commands (`run`, `console`, `help`)
283
- - Validates the Ruby environment (checks for `RUBY_BOX=1` and Ruby::Box support)
284
- - Delegates to `Boxwerk::Setup` for the boot process
285
- - Executes the requested command in the root package's box context
286
-
287
- #### `Boxwerk::Setup`
288
-
289
- The setup orchestrator that:
290
- - Searches up the directory tree to find the root `package.yml`
291
- - Creates a `Boxwerk::Graph` instance to build and validate the dependency graph
292
- - Creates a `Boxwerk::Registry` instance to track booted packages
293
- - Calls `Boxwerk::Loader.boot_all` to boot all packages in topological order
294
- - Returns the loaded graph for introspection
295
-
296
- #### `Boxwerk::Graph`
297
-
298
- The dependency graph builder that:
299
- - Parses the root `package.yml` and recursively discovers all package dependencies
300
- - Builds a directed acyclic graph (DAG) of package relationships
301
- - Validates that there are no circular dependencies
302
- - Performs topological sorting to determine boot order (dependencies before consumers)
303
- - Provides access to all packages in the graph
304
-
305
- #### `Boxwerk::Package`
306
-
307
- The package manifest parser that:
308
- - Represents a single package with its configuration
309
- - Parses `package.yml` files to extract exports and imports
310
- - Normalizes the polymorphic import syntax (String, Array, Hash)
311
- - Stores the package path, name, exports, imports, and box reference
312
- - Tracks which exports have been loaded via `loaded_exports` hash (export name โ†’ file path)
313
- - Tracks whether the package has been booted
314
-
315
- #### `Boxwerk::Loader`
316
-
317
- The package loader that:
318
- - Creates a new `Ruby::Box` for each package (including the root package)
319
- - Loads exported constants lazily on-demand when they are imported by other packages
320
- - Uses Zeitwerk naming conventions to discover file locations for exported constants
321
- - Caches loaded exports in `package.loaded_exports` to avoid redundant file loading
322
- - Wires imports by injecting constants from dependency boxes into consumer boxes
323
- - Implements all four import strategies (default namespace, aliased namespace, selective import, selective rename)
324
- - Handles the single-export optimization for namespace imports
325
- - Registers each booted package in the registry
326
- - Only loads files that define exported constants, never loading non-exported code
327
-
328
- #### `Boxwerk::Registry`
329
-
330
- The package registry that:
331
- - Tracks all booted package instances
332
- - Allows packages to be retrieved by name during the wiring phase
333
- - Ensures each package is only booted once
334
- - Provides a clean interface for package lookup
335
-
336
- ## Example
337
-
338
- See the [example/](example/) directory for a complete working example with:
339
-
340
- - Multi-package application
341
- - Gem usage
342
- - Transitive dependency demonstration
343
- - Isolation verification
344
- - Console usage examples
345
-
346
- Run it with:
347
-
348
- ```bash
349
- cd example
350
- RUBY_BOX=1 boxwerk run app.rb
351
- ```
352
-
353
- Or explore interactively:
354
-
355
- ```bash
356
- cd example
357
- RUBY_BOX=1 boxwerk console
358
- ```
88
+ - [`examples/minimal/`](examples/minimal/) โ€” Three packages, dependency enforcement, no gems
89
+ - [`examples/complex/`](examples/complex/) โ€” Namespaced constants, privacy, per-package gems, tests
90
+ - [`examples/rails/`](examples/rails/) โ€” Usage with Rails
359
91
 
360
92
  ## Development
361
93
 
362
- After checking out the repo, run `bin/setup` to install dependencies. Then, run the tests:
363
-
364
94
  ```bash
365
- RUBY_BOX=1 bundle exec rake test
95
+ bundle install # Install dependencies
96
+ RUBY_BOX=1 bundle exec rake # Run all tests (unit, e2e, examples)
97
+ bundle exec rake format # Format code
366
98
  ```
367
99
 
368
- To install this gem onto your local machine, run `bundle exec rake install`.
369
-
370
100
  ## License
371
101
 
372
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
373
-
374
- ## Contribution
375
-
376
- Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you shall be dual licensed as above, without any additional terms or conditions.
102
+ Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -1,8 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/gem_tasks'
4
- require 'minitest/test_task'
5
4
 
6
- Minitest::TestTask.create
5
+ STREE_FILES = '**/*.rb **/Rakefile'
6
+ EXAMPLES_DIR = File.join(__dir__, 'examples')
7
+ EXAMPLE_DIRS =
8
+ Dir.glob(File.join(EXAMPLES_DIR, '*')).select { |d| File.directory?(d) }.sort
7
9
 
8
- task default: :test
10
+ task :test do
11
+ $LOAD_PATH.unshift(File.join(__dir__, 'test'))
12
+ Dir.glob('test/boxwerk/**/*_test.rb').sort.each { |f| require_relative f }
13
+ end
14
+
15
+ task :e2e do
16
+ sh('ruby', 'test/e2e_test.rb')
17
+ end
18
+
19
+ namespace :example do
20
+ EXAMPLE_DIRS.each do |dir|
21
+ name = File.basename(dir)
22
+ desc "Run the #{name} example"
23
+ task name.to_sym do
24
+ dir = File.join(EXAMPLES_DIR, name)
25
+ abort("Example not found: #{name}") unless File.directory?(dir)
26
+
27
+ puts "==> example:#{name}"
28
+ run_script = File.join(dir, 'run.sh')
29
+ if File.exist?(run_script)
30
+ sh({ 'RUBY_BOX' => '1' }, 'sh', run_script, chdir: dir)
31
+ else
32
+ sh(
33
+ { 'RUBY_BOX' => '1' },
34
+ File.join(dir, 'bin', 'boxwerk'),
35
+ 'exec',
36
+ '--all',
37
+ 'rake',
38
+ chdir: dir,
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ task examples: EXAMPLE_DIRS.map { |d| "example:#{File.basename(d)}" }
46
+
47
+ task :format do
48
+ sh "bundle exec stree write #{STREE_FILES}"
49
+ end
50
+
51
+ task default: %i[test e2e examples]