boxwerk 0.2.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.
- checksums.yaml +4 -4
- data/AGENTS.md +24 -0
- data/ARCHITECTURE.md +264 -0
- data/CHANGELOG.md +59 -11
- data/README.md +56 -174
- data/Rakefile +46 -3
- data/TODO.md +317 -0
- data/USAGE.md +505 -0
- data/exe/boxwerk +55 -14
- data/lib/boxwerk/autoloader_mixin.rb +65 -0
- data/lib/boxwerk/box_manager.rb +405 -0
- data/lib/boxwerk/cli.rb +776 -37
- data/lib/boxwerk/constant_resolver.rb +236 -0
- data/lib/boxwerk/gem_resolver.rb +235 -0
- data/lib/boxwerk/gemfile_require_parser.rb +50 -0
- data/lib/boxwerk/global_context.rb +85 -0
- data/lib/boxwerk/package.rb +76 -24
- data/lib/boxwerk/package_context.rb +103 -0
- data/lib/boxwerk/package_resolver.rb +122 -0
- data/lib/boxwerk/privacy_checker.rb +159 -0
- data/lib/boxwerk/setup.rb +124 -16
- data/lib/boxwerk/version.rb +1 -1
- data/lib/boxwerk/zeitwerk_scanner.rb +172 -0
- data/lib/boxwerk.rb +30 -3
- metadata +54 -11
- data/lib/boxwerk/graph.rb +0 -53
- data/lib/boxwerk/loader.rb +0 -149
- data/lib/boxwerk/registry.rb +0 -26
data/USAGE.md
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
# Usage
|
|
2
|
+
|
|
3
|
+
Complete usage guide for Boxwerk — runtime package isolation for Ruby.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Ruby 4.0+ with the `RUBY_BOX=1` environment variable set before process boot
|
|
8
|
+
- `package.yml` files ([Packwerk](https://github.com/Shopify/packwerk) format)
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
### With Bundler
|
|
13
|
+
|
|
14
|
+
Add `boxwerk` to your project's `gems.rb` or `Gemfile`:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# gems.rb
|
|
18
|
+
source 'https://rubygems.org'
|
|
19
|
+
|
|
20
|
+
gem 'boxwerk'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Install and set up:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bundle install # Install gems (including boxwerk)
|
|
27
|
+
bundle binstubs boxwerk # Create bin/boxwerk binstub
|
|
28
|
+
bin/boxwerk install # Install per-package gems (works without pre-installed project gems)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Run your application:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
RUBY_BOX=1 bin/boxwerk run main.rb
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Without Bundler
|
|
38
|
+
|
|
39
|
+
Boxwerk works without any Gemfile. Install it as a system gem:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
gem install boxwerk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Run directly:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
RUBY_BOX=1 boxwerk run main.rb
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
In this mode, no gems are loaded into the global context (except Boxwerk itself). Per-package gems are still supported if Bundler is available on the system.
|
|
52
|
+
|
|
53
|
+
## Package Configuration (package.yml)
|
|
54
|
+
|
|
55
|
+
Standard [Packwerk](https://github.com/Shopify/packwerk) format:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
# packs/finance/package.yml
|
|
59
|
+
enforce_dependencies: true
|
|
60
|
+
dependencies:
|
|
61
|
+
- packs/util
|
|
62
|
+
- packs/billing
|
|
63
|
+
|
|
64
|
+
enforce_privacy: true
|
|
65
|
+
public_path: public/ # default; directory of public constants
|
|
66
|
+
private_constants:
|
|
67
|
+
- "::InternalHelper" # explicitly blocked even if in public_path
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Field | Type | Default | Description |
|
|
71
|
+
|------------------------|----------|-----------|-------------------------------------------------|
|
|
72
|
+
| `enforce_dependencies` | bool | `false` | Block access to undeclared dependencies |
|
|
73
|
+
| `dependencies` | list | `[]` | Direct dependency package paths |
|
|
74
|
+
| `enforce_privacy` | bool | `false` | Restrict external access to public constants |
|
|
75
|
+
| `public_path` | string | `public/` | Directory containing the package's public API |
|
|
76
|
+
| `private_constants` | list | `[]` | Constants blocked even if in the public path |
|
|
77
|
+
|
|
78
|
+
## `pack_public: true` Sigil
|
|
79
|
+
|
|
80
|
+
Files outside the `public_path` can be individually marked public by adding a comment sigil in the first 5 lines:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# pack_public: true
|
|
84
|
+
class SpecialService
|
|
85
|
+
# accessible to dependents even though it lives in lib/, not public/
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The sigil is matched with `#.*pack_public:\s*true`.
|
|
90
|
+
|
|
91
|
+
## CLI Reference
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
boxwerk <command> [options] [args...]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Commands
|
|
98
|
+
|
|
99
|
+
#### `boxwerk run <script.rb> [args...]`
|
|
100
|
+
|
|
101
|
+
Run a Ruby script in a package box.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
boxwerk run main.rb # Run in root package box
|
|
105
|
+
boxwerk run --package packs/finance main.rb # Run in a specific package box
|
|
106
|
+
boxwerk run --global main.rb # Run in global context (no package)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
#### `boxwerk exec <command> [args...]`
|
|
110
|
+
|
|
111
|
+
Execute a command in the boxed environment. Boxwerk looks for the command in this order:
|
|
112
|
+
|
|
113
|
+
1. **Project binstub** — `./bin/<command>` in the project root
|
|
114
|
+
2. **Gem binstub** — resolved via `Gem.bin_path`
|
|
115
|
+
3. **Shell command** — falls back to running the command as a shell process in the package directory
|
|
116
|
+
|
|
117
|
+
Project binstubs take precedence, allowing custom command entry points (e.g. a `bin/rails` that sets `APP_PATH` and requires `rails/commands`).
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
boxwerk exec rake test # Root package
|
|
121
|
+
boxwerk exec --package packs/util rake test # Specific package
|
|
122
|
+
boxwerk exec --all rake test # All packages sequentially
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
With `--all`, each package runs in a separate subprocess for clean isolation (avoids `at_exit` conflicts from test frameworks like Minitest).
|
|
126
|
+
|
|
127
|
+
#### `boxwerk console [irb-args...]`
|
|
128
|
+
|
|
129
|
+
Start an IRB console with package constants accessible. IRB runs in `Ruby::Box.root` with a composite resolver that provides the target package's constants.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
boxwerk console # Root package context
|
|
133
|
+
boxwerk console --package packs/finance # Specific package context
|
|
134
|
+
boxwerk console --global # Global context
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
IRB autocomplete is disabled (box-scoped constants are not visible to the completer).
|
|
138
|
+
|
|
139
|
+
#### `boxwerk info`
|
|
140
|
+
|
|
141
|
+
Boot the application and show runtime autoload structure: config, dependency tree, global section, per-package enforcements/dependencies/autoload dirs/gems/constants. Also reports gem version conflicts.
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
RUBY_BOX=1 boxwerk info
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Output sections:
|
|
148
|
+
- **Config** — `boxwerk.yml` settings with defaults filled in
|
|
149
|
+
- **Dependency Graph** — tree view; circular dependencies are marked `(circular)`
|
|
150
|
+
- **Global** — boot script, autoload or `eager_load` dirs (label is `eager_load` when eager loading enabled), gems
|
|
151
|
+
- **Packages** — each package with enforcements, dependencies, `autoload`/`eager_load` dirs, `collapse` dirs, `ignore` dirs, `pack_public` constants, explicit private constants, and direct gems
|
|
152
|
+
|
|
153
|
+
Requires `RUBY_BOX=1` (boots the application to gather runtime autoload information).
|
|
154
|
+
|
|
155
|
+
When eager loading is enabled (`eager_load_global` / `eager_load_packages`), the autoload section label becomes `eager_load`.
|
|
156
|
+
|
|
157
|
+
#### `boxwerk install`
|
|
158
|
+
|
|
159
|
+
Run `bundle install` for every package that has a `Gemfile` or `gems.rb`. Installs global (root) gems first, then packages.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
bin/boxwerk install
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Does not require `RUBY_BOX=1`. Works without pre-installing global project gems first — the binstub skips `bundler/setup` for this command so you can run it as the first step after cloning a project.
|
|
166
|
+
|
|
167
|
+
#### `boxwerk help` / `boxwerk version`
|
|
168
|
+
|
|
169
|
+
Show usage or version.
|
|
170
|
+
|
|
171
|
+
### Options
|
|
172
|
+
|
|
173
|
+
| Flag | Short | Applies to | Description |
|
|
174
|
+
|---------------------------|-------|-------------------------|----------------------------------------------------|
|
|
175
|
+
| `--package <name>` | `-p` | `exec`, `run`, `console`| Run in a specific package box (default: `.`) |
|
|
176
|
+
| `--all` | `-a` | `exec` | Run for all packages sequentially (subprocesses) |
|
|
177
|
+
| `--global` | `-g` | `exec`, `run`, `console`| Run in the global context (no package) |
|
|
178
|
+
| `--package-paths <paths>` | | `exec`, `run`, `console`| Comma-separated package path globs (override `boxwerk.yml`) |
|
|
179
|
+
| `--eager-load-global` | | `exec`, `run`, `console`| Enable global eager loading |
|
|
180
|
+
| `--no-eager-load-global` | | `exec`, `run`, `console`| Disable global eager loading |
|
|
181
|
+
| `--eager-load-packages` | | `exec`, `run`, `console`| Enable package eager loading |
|
|
182
|
+
| `--no-eager-load-packages`| | `exec`, `run`, `console`| Disable package eager loading |
|
|
183
|
+
|
|
184
|
+
CLI config options override `boxwerk.yml` values. This enables quick configuration without creating a config file.
|
|
185
|
+
|
|
186
|
+
Package names passed to `--package` are normalized: leading `./` and trailing `/` are stripped, so `./packs/loyalty`, `packs/loyalty/`, and `packs/loyalty` all refer to the same package.
|
|
187
|
+
|
|
188
|
+
## Per-Package Gems
|
|
189
|
+
|
|
190
|
+
Each package can have its own `Gemfile`/`gems.rb` and corresponding lockfile. Different packages can use different versions of the same gem.
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
packs/billing/
|
|
194
|
+
├── package.yml
|
|
195
|
+
├── Gemfile # gem 'stripe', '~> 5.0'
|
|
196
|
+
├── Gemfile.lock
|
|
197
|
+
└── lib/
|
|
198
|
+
└── payment.rb # Stripe auto-required, ready to use
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Gem Isolation Model
|
|
202
|
+
|
|
203
|
+
- **Global gems** (root `Gemfile`) are loaded in the global context and inherited by all child boxes via `$LOADED_FEATURES` snapshot at box creation time
|
|
204
|
+
- **Per-package gems** are resolved from lockfiles and added to each box's `$LOAD_PATH` independently
|
|
205
|
+
- **Auto-required:** Gems declared in a package's `Gemfile`/`gems.rb` are automatically required in the package box (like Bundler's default behaviour). No manual `require` needed
|
|
206
|
+
- **`require: false`** — Gems declared with `require: false` are added to `$LOAD_PATH` but not auto-required
|
|
207
|
+
- **Custom require:** `gem 'foo', require: 'foo/bar'` auto-requires `foo/bar` instead of `foo`
|
|
208
|
+
- **Gems do NOT leak** across package boundaries — package A cannot see package B's gems, even if A depends on B
|
|
209
|
+
- **Cross-package version differences** are safe — each box has its own isolated `$LOAD_PATH`
|
|
210
|
+
- **Global override warning:** If a package defines a gem that's also in the root `Gemfile` at a different version, both versions load into memory (functionally correct but wastes memory). Boxwerk warns at boot time
|
|
211
|
+
- **Shared global gems:** Add a gem to the root `Gemfile` without `require: false` to share a single copy across all packages
|
|
212
|
+
|
|
213
|
+
Run `boxwerk install` to install gems for all packages.
|
|
214
|
+
|
|
215
|
+
## Constant Resolution
|
|
216
|
+
|
|
217
|
+
### Intra-Package: autoload
|
|
218
|
+
|
|
219
|
+
Each package's constants are registered as `autoload` entries in its box. When code references `Calculator`, Ruby's built-in autoload loads the file — standard Ruby behaviour.
|
|
220
|
+
|
|
221
|
+
### Cross-Package: const_missing
|
|
222
|
+
|
|
223
|
+
When a constant is not found in the current box, `Object.const_missing` fires. Boxwerk's per-box handler:
|
|
224
|
+
|
|
225
|
+
1. Iterates through declared direct dependencies
|
|
226
|
+
2. Checks each dependency for the constant (via file index or `const_get`)
|
|
227
|
+
3. Enforces privacy rules
|
|
228
|
+
4. Returns the constant value from the dependency's box
|
|
229
|
+
5. Raises `NameError` if no dependency has it
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
# Simplified flow:
|
|
233
|
+
class Object
|
|
234
|
+
def self.const_missing(const_name)
|
|
235
|
+
deps.each do |dep|
|
|
236
|
+
next unless dep.has_constant?(const_name)
|
|
237
|
+
check_privacy!(const_name, dep)
|
|
238
|
+
return dep.box.const_get(const_name)
|
|
239
|
+
end
|
|
240
|
+
raise NameError, "uninitialized constant #{const_name}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Constants are **not** wrapped in namespaces. `Invoice` is accessed as `Invoice`, not `Finance::Invoice`.
|
|
246
|
+
|
|
247
|
+
### Namespace Module Resolution
|
|
248
|
+
|
|
249
|
+
Parent modules are resolved by loading child files. If a file index has `Menu::Item` but no direct `Menu` entry, referencing `Menu` triggers autoload of a child file, which defines the `Menu` module as a side effect.
|
|
250
|
+
|
|
251
|
+
## Privacy Enforcement
|
|
252
|
+
|
|
253
|
+
Privacy is checked at `const_missing` time when resolving cross-package constants:
|
|
254
|
+
|
|
255
|
+
- **`public_path`** — Only files in this directory (default: `public/`) define the package's public API. Constants outside it are blocked.
|
|
256
|
+
- **`private_constants`** — Explicitly private constants, blocked even if in the public path.
|
|
257
|
+
- **`pack_public: true` sigil** — Files outside the public path can opt in to public visibility.
|
|
258
|
+
|
|
259
|
+
Violations raise `NameError` with a descriptive message:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
Privacy violation: 'InternalHelper' is private to 'packs/finance'.
|
|
263
|
+
Only constants in the public path are accessible.
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Root Package vs Global Context
|
|
267
|
+
|
|
268
|
+
These are different concepts:
|
|
269
|
+
|
|
270
|
+
- **Root package** (`.`) — Your top-level `package.yml`. Gets its own `Ruby::Box` like any other package. Has dependencies and constants. This is where your application code runs by default.
|
|
271
|
+
- **Global context** (`Ruby::Box.root`) — Where global gems are loaded via `Bundler.require`. Contains global gems and constants. All child boxes are copied from it.
|
|
272
|
+
|
|
273
|
+
The global context is an implementation detail of how `Ruby::Box` works. Constants and files loaded in the global context before package boxes are created are inherited by **all** package boxes. This is because each `Ruby::Box.new` creates a snapshot of `Ruby::Box.root` at that moment.
|
|
274
|
+
|
|
275
|
+
This has important implications:
|
|
276
|
+
|
|
277
|
+
- Global gems loaded via `Bundler.require` are available everywhere (loaded before boxes)
|
|
278
|
+
- Constants defined in `global/` files are available everywhere (required before boxes)
|
|
279
|
+
- Code in `global/boot.rb` runs before any package boxes exist
|
|
280
|
+
- Anything loaded **after** box creation is only visible in the box that loaded it
|
|
281
|
+
|
|
282
|
+
Use `--global` / `-g` to run commands in the global context directly. A composite resolver is installed on `Ruby::Box.root` so that **all** package constants are accessible — useful for scripts, debugging, or tools that need cross-package access without picking a single package.
|
|
283
|
+
|
|
284
|
+
## Global Gems
|
|
285
|
+
|
|
286
|
+
Gems in the root `Gemfile`/`gems.rb` are loaded in `Ruby::Box.root` during boot:
|
|
287
|
+
|
|
288
|
+
1. `Bundler.setup` + `Bundler.require` run in the global context
|
|
289
|
+
2. All loaded gems become available in child boxes via `$LOADED_FEATURES` snapshot
|
|
290
|
+
3. Use `require: false` to keep a gem on `$LOAD_PATH` without loading it at boot
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
# gems.rb
|
|
294
|
+
gem 'activesupport' # loaded globally, available everywhere
|
|
295
|
+
gem 'pry', require: false # on $LOAD_PATH but not loaded
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
> **Note:** The root `gems.rb`/`Gemfile` is always for global gems shared across all packages. If your top-level package needs "package private" gems, use an implicit root (no `package.yml` at root) and create a `packs/main` package as your entry point with its own `gems.rb`.
|
|
299
|
+
|
|
300
|
+
### Gems with Internal Autoloading
|
|
301
|
+
|
|
302
|
+
Some gems (like Rails) use Zeitwerk internally to autoload their own constants. When loaded via `Bundler.require` in the global context, these autoloads are registered as pending entries in `Ruby::Box.root`. Because child boxes inherit a snapshot of the root box at creation time, pending autoloads may not resolve correctly in child boxes.
|
|
303
|
+
|
|
304
|
+
Boxwerk runs `Zeitwerk::Loader.eager_load_all` after global boot to resolve all pending autoloads. If your gem needs additional setup before eager loading (e.g. requiring sub-components), do this in `global/boot.rb`:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
# global/boot.rb
|
|
308
|
+
require "active_record/railtie" # register Zeitwerk autoloads
|
|
309
|
+
require "action_controller/railtie"
|
|
310
|
+
# Boxwerk runs Zeitwerk::Loader.eager_load_all after this script
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Testing
|
|
314
|
+
|
|
315
|
+
Run tests through Boxwerk to enforce package isolation:
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
boxwerk exec rake test # Root package tests
|
|
319
|
+
boxwerk exec --package packs/finance rake test # Specific package tests
|
|
320
|
+
boxwerk exec --all rake test # All packages sequentially
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Each `--all` run spawns a separate subprocess per package for clean isolation — test frameworks like Minitest register tests globally via `at_exit`, which would conflict across packages in a single process.
|
|
324
|
+
|
|
325
|
+
## Configuration (`boxwerk.yml`)
|
|
326
|
+
|
|
327
|
+
An optional `boxwerk.yml` file at the project root configures Boxwerk behaviour.
|
|
328
|
+
|
|
329
|
+
```yaml
|
|
330
|
+
# boxwerk.yml
|
|
331
|
+
package_paths:
|
|
332
|
+
- "packs/*" # default: ["**/"]
|
|
333
|
+
eager_load_global: true # default: true
|
|
334
|
+
eager_load_packages: false # default: false
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
| Field | Type | Default | Description |
|
|
338
|
+
|-----------------------|------|-----------|------------------------------------------------------------|
|
|
339
|
+
| `package_paths` | list | `["**/"]` | Glob patterns for where to search for `package.yml` files |
|
|
340
|
+
| `eager_load_global` | bool | `true` | Eager-load `global/` files and Zeitwerk constants at boot |
|
|
341
|
+
| `eager_load_packages` | bool | `false` | Eager-load all constants in each package box after boot |
|
|
342
|
+
|
|
343
|
+
By default, Boxwerk searches everywhere (`**/`) for `package.yml` files. Set `package_paths` to restrict the search to specific directories.
|
|
344
|
+
|
|
345
|
+
### Eager Loading
|
|
346
|
+
|
|
347
|
+
- **`eager_load_global`** — When `true` (default), requires all files in `global/` and any dirs registered via `Boxwerk.global.autoloader.push_dir`, then runs `Zeitwerk::Loader.eager_load_all`. This ensures constants are defined before child boxes are created. When `false`, global constants are registered as lazy autoloads (accessible on demand, e.g. in `global/boot.rb`) but not eagerly required.
|
|
348
|
+
- **`eager_load_packages`** — When `true`, eager-loads all constants in each package box immediately after it boots. When `false` (default), constants are lazy-loaded via autoload on first access.
|
|
349
|
+
|
|
350
|
+
## Implicit Root Package
|
|
351
|
+
|
|
352
|
+
If no `package.yml` exists at the project root, Boxwerk creates an implicit root package with:
|
|
353
|
+
|
|
354
|
+
- `enforce_dependencies: false`
|
|
355
|
+
- `enforce_privacy: false`
|
|
356
|
+
- Automatic dependencies on all discovered packages
|
|
357
|
+
|
|
358
|
+
This is useful for gradually adopting Boxwerk — you can start with just sub-packages and no root `package.yml`. The implicit root can access constants from all packages without declaring explicit dependencies.
|
|
359
|
+
|
|
360
|
+
## Global Boot
|
|
361
|
+
|
|
362
|
+
### `global/` Directory
|
|
363
|
+
|
|
364
|
+
An optional `global/` directory at the project root provides global constants and initialization. Files in `global/` are required in the root box before package boxes are created, so all definitions are inherited by every package box.
|
|
365
|
+
|
|
366
|
+
Files follow Zeitwerk conventions:
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
global/
|
|
370
|
+
├── boot.rb # Global boot script (optional)
|
|
371
|
+
├── config.rb # Config
|
|
372
|
+
└── middleware.rb # Middleware
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
# global/config.rb
|
|
377
|
+
module Config
|
|
378
|
+
SHOP_NAME = ENV.fetch('SHOP_NAME', 'My App')
|
|
379
|
+
CURRENCY = '$'
|
|
380
|
+
end
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### `global/boot.rb`
|
|
384
|
+
|
|
385
|
+
An optional `global/boot.rb` script runs in the root box after global files are loaded but before package boxes are created. Use it for initialization that all packages should inherit.
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
# global/boot.rb
|
|
389
|
+
require 'dotenv/load'
|
|
390
|
+
puts "Booting #{Config::SHOP_NAME}..."
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
#### `Boxwerk.global.autoloader`
|
|
394
|
+
|
|
395
|
+
Use `Boxwerk.global.autoloader` in `global/boot.rb`, a package `boot.rb`, or anywhere in the application to register additional root-level autoload directories. Constants loaded this way are available in all package boxes.
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
# global/boot.rb
|
|
399
|
+
# Load shared utilities from a custom lib/ dir
|
|
400
|
+
Boxwerk.global.autoloader.push_dir(File.expand_path('../lib', __dir__))
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Methods:
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
Boxwerk.global.autoloader.push_dir("lib") # Register lazy autoloads
|
|
407
|
+
Boxwerk.global.autoloader.collapse("lib/utils") # Collapse namespace
|
|
408
|
+
Boxwerk.global.autoloader.ignore("lib/utils") # Ignore from autoloading
|
|
409
|
+
Boxwerk.global.autoloader.setup # Register any pending dirs
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
`push_dir` and `collapse` auto-call `setup` (lazy autoload registration), so constants are accessible immediately in `boot.rb` via the autoload mechanism. Files are NOT eagerly required by `push_dir`. When `eager_load_global: true`, Boxwerk eagerly requires all registered dirs after `global/boot.rb` so child boxes inherit the constants eagerly.
|
|
413
|
+
|
|
414
|
+
### Root-Level `boot.rb`
|
|
415
|
+
|
|
416
|
+
A `boot.rb` at the project root is a **root package** boot script — it runs in the root package's box (like any other package's `boot.rb`). This is different from `global/boot.rb` which runs in the root box.
|
|
417
|
+
|
|
418
|
+
### Use Cases
|
|
419
|
+
|
|
420
|
+
- **`global/boot.rb`** — Load environment variables, define global config, manually eager load constants (e.g. Rails internals)
|
|
421
|
+
- **`boot.rb`** — Root package initialization, e.g. booting Rails (see [Rails example](examples/rails/)). Runs after all packages are booted
|
|
422
|
+
|
|
423
|
+
## Per-Package Boot Scripts
|
|
424
|
+
|
|
425
|
+
Each package can have an optional `boot.rb` that runs after the package's own constants are scanned and per-package gems are loaded, but before cross-package constants are wired. It can be used to configure additional autoload dirs and collapse:
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
# packs/models/boot.rb
|
|
429
|
+
pkg = Boxwerk.package
|
|
430
|
+
|
|
431
|
+
pkg.name # => "packs/models"
|
|
432
|
+
pkg.root? # => false
|
|
433
|
+
pkg.config # => frozen hash of package.yml values
|
|
434
|
+
pkg.root_path # => absolute path to the package directory
|
|
435
|
+
pkg.autoloader # => autoload configuration object
|
|
436
|
+
|
|
437
|
+
pkg.autoloader.push_dir("models")
|
|
438
|
+
pkg.autoloader.collapse("lib/concerns") # Promotes lib/concerns/* to parent namespace
|
|
439
|
+
pkg.autoloader.ignore("lib/legacy") # Excludes lib/legacy/* from autoloading
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
`collapse` removes the intermediate namespace directory from the constant hierarchy. For example, collapsing `lib/analytics/formatters` means files in that directory are accessible as `Analytics::CsvFormatter` rather than `Analytics::Formatters::CsvFormatter`. The `Formatters` intermediate constant is removed from the box.
|
|
443
|
+
|
|
444
|
+
`ignore` prevents files in the directory from being autoloaded. Accessing any constant from that directory raises `NameError`.
|
|
445
|
+
|
|
446
|
+
### `autoloader.setup`
|
|
447
|
+
|
|
448
|
+
`push_dir` and `collapse` automatically call `setup`, registering constants immediately so they are available during `boot.rb` execution. You can also call `autoloader.setup` explicitly to ensure dirs are registered at a specific point:
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
# packs/svc/boot.rb
|
|
452
|
+
pkg = Boxwerk.package
|
|
453
|
+
pkg.autoloader.push_dir("extras")
|
|
454
|
+
|
|
455
|
+
# Helper is available immediately (push_dir auto-called setup)
|
|
456
|
+
Helper.configure(ENV["API_KEY"])
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
`setup` can be called multiple times — each call registers only the dirs added since the last call.
|
|
460
|
+
|
|
461
|
+
`Boxwerk.package` returns a `PackageContext` accessible from anywhere inside a package box. The `BOXWERK_PACKAGE` constant is also set in each package box for direct access.
|
|
462
|
+
|
|
463
|
+
### Autoloader Public API
|
|
464
|
+
|
|
465
|
+
Both `Boxwerk.global.autoloader` and `Boxwerk.package.autoloader` expose the same four methods:
|
|
466
|
+
|
|
467
|
+
| Method | Description |
|
|
468
|
+
|---|---|
|
|
469
|
+
| `push_dir(dir)` | Add a dir to autoload roots; registers lazy autoloads immediately |
|
|
470
|
+
| `collapse(dir)` | Collapse a subdir into its parent namespace; registers immediately |
|
|
471
|
+
| `ignore(dir)` | Exclude a dir from autoloading entirely |
|
|
472
|
+
| `setup` | Register any pending dirs (auto-called by `push_dir` and `collapse`) |
|
|
473
|
+
|
|
474
|
+
All methods return `self`, allowing chaining.
|
|
475
|
+
|
|
476
|
+
### Monkey Patch Isolation
|
|
477
|
+
|
|
478
|
+
Because each package runs in its own `Ruby::Box`, monkey patches defined in a
|
|
479
|
+
package are isolated to that box:
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
# packs/kitchen/boot.rb
|
|
483
|
+
class String
|
|
484
|
+
def to_order_ticket
|
|
485
|
+
"🎫 #{upcase}"
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Code inside the kitchen package can call `"Latte".to_order_ticket`, but other
|
|
491
|
+
packages and the root context will not see the method.
|
|
492
|
+
|
|
493
|
+
## Circular Dependencies
|
|
494
|
+
|
|
495
|
+
Boxwerk allows circular dependencies. Both packages in a cycle are booted; the first visited in DFS order goes first. Dependencies in cycles are still wired normally.
|
|
496
|
+
|
|
497
|
+
## Relaxed Dependency Enforcement
|
|
498
|
+
|
|
499
|
+
When `enforce_dependencies: false`, `const_missing` searches ALL packages — not just declared dependencies. Explicit dependencies are searched first (in declared order), then remaining packages. Privacy rules still apply per-package.
|
|
500
|
+
|
|
501
|
+
## Examples
|
|
502
|
+
|
|
503
|
+
- [`examples/minimal/`](examples/minimal/) — Simplest setup: three packages, dependency enforcement, no gems
|
|
504
|
+
- [`examples/complex/`](examples/complex/) — Full-featured: namespaced constants, privacy enforcement, per-package gems, global gems, and tests
|
|
505
|
+
- [`examples/rails/`](examples/rails/) — Rails with ActiveRecord, foundation package, privacy
|
data/exe/boxwerk
CHANGED
|
@@ -1,20 +1,61 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
# If Bundler has already been set up (via `bundle exec` or a Bundler binstub),
|
|
5
|
+
# re-exec in a clean Ruby process so Boxwerk controls gem loading from scratch.
|
|
6
|
+
# This prevents gems being loaded twice (once by Bundler in the main box, once
|
|
7
|
+
# by Boxwerk in the root box).
|
|
8
|
+
if defined?(Bundler)
|
|
9
|
+
Bundler.with_unbundled_env { exec(RbConfig.ruby, __FILE__, *ARGV) }
|
|
8
10
|
end
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
boxwerk_lib = File.expand_path('../lib', __dir__)
|
|
13
|
+
boxwerk_exe = File.expand_path(__FILE__)
|
|
14
|
+
|
|
15
|
+
# Commands that don't need Ruby::Box (no package boot required).
|
|
16
|
+
NO_BOX_COMMANDS = %w[install help --help -h version --version -v].freeze
|
|
17
|
+
# Subset that don't need Bundler at all — safe to run without any gems installed.
|
|
18
|
+
STANDALONE_COMMANDS = %w[install help --help -h version --version -v].freeze
|
|
19
|
+
|
|
20
|
+
if NO_BOX_COMMANDS.include?(ARGV[0])
|
|
21
|
+
$LOAD_PATH.unshift(boxwerk_lib)
|
|
22
|
+
require 'boxwerk'
|
|
23
|
+
|
|
24
|
+
unless STANDALONE_COMMANDS.include?(ARGV[0])
|
|
25
|
+
gemfile = %w[gems.rb Gemfile].find { |f| File.exist?(f) }
|
|
26
|
+
if gemfile
|
|
27
|
+
require 'bundler/setup'
|
|
28
|
+
Bundler.require
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Boxwerk::CLI.run(ARGV, exe_path: boxwerk_exe)
|
|
33
|
+
else
|
|
34
|
+
unless defined?(Ruby::Box)
|
|
35
|
+
$stderr.puts 'Error: Ruby::Box is not available.'
|
|
36
|
+
$stderr.puts 'Boxwerk requires Ruby 4.0 or later with Ruby::Box support.'
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
15
39
|
|
|
16
|
-
Ruby::Box.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
40
|
+
unless Ruby::Box.enabled?
|
|
41
|
+
$stderr.puts 'Error: Ruby::Box is not enabled.'
|
|
42
|
+
$stderr.puts 'Set the RUBY_BOX=1 environment variable before starting Ruby.'
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Load and run in the root box. Boxwerk is loaded first (before Bundler
|
|
47
|
+
# restricts $LOAD_PATH), then project gems are loaded via Bundler. All
|
|
48
|
+
# definitions end up in the root box, inherited by child boxes.
|
|
49
|
+
Ruby::Box.root.eval(<<~RUBY)
|
|
50
|
+
$LOAD_PATH.unshift(#{boxwerk_lib.inspect})
|
|
51
|
+
require 'boxwerk'
|
|
52
|
+
|
|
53
|
+
gemfile = %w[gems.rb Gemfile].find { |f| File.exist?(f) }
|
|
54
|
+
if gemfile
|
|
55
|
+
require 'bundler/setup'
|
|
56
|
+
Bundler.require
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Boxwerk::CLI.run(ARGV, exe_path: #{boxwerk_exe.inspect})
|
|
60
|
+
RUBY
|
|
61
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boxwerk
|
|
4
|
+
# Shared autoload configuration API included by both
|
|
5
|
+
# {GlobalContext::Autoloader} and {PackageContext::Autoloader}.
|
|
6
|
+
#
|
|
7
|
+
# Provides the four public methods available in boot scripts:
|
|
8
|
+
# {#push_dir}, {#collapse}, {#ignore}, and {#setup}.
|
|
9
|
+
module AutoloaderMixin
|
|
10
|
+
# Adds +dir+ to autoload paths and immediately registers lazy autoloads.
|
|
11
|
+
# Constants in the directory are accessible via autoload from this point on.
|
|
12
|
+
# @param dir [String] Absolute or relative path to an autoload root.
|
|
13
|
+
# @return [self]
|
|
14
|
+
def push_dir(dir)
|
|
15
|
+
@push_dirs << dir
|
|
16
|
+
setup
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Collapses +dir+, mapping its files to the parent namespace rather than
|
|
21
|
+
# introducing an intermediate namespace for the directory itself.
|
|
22
|
+
# @param dir [String] Absolute or relative path to a directory to collapse.
|
|
23
|
+
# @return [self]
|
|
24
|
+
def collapse(dir)
|
|
25
|
+
@collapse_dirs << dir
|
|
26
|
+
setup
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Ignores +dir+ from autoloading entirely.
|
|
31
|
+
# @param dir [String] Absolute or relative path to a directory to ignore.
|
|
32
|
+
# @return [self]
|
|
33
|
+
def ignore(dir)
|
|
34
|
+
@ignore_dirs << dir
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Registers lazy autoloads for all dirs added since the last +setup+ call.
|
|
39
|
+
# Called automatically by {#push_dir} and {#collapse}; only call explicitly
|
|
40
|
+
# if you need to trigger registration outside of those methods.
|
|
41
|
+
# @return [self]
|
|
42
|
+
def setup
|
|
43
|
+
new_push = @push_dirs[@setup_index[:push]..]
|
|
44
|
+
new_collapse = @collapse_dirs[@setup_index[:collapse]..]
|
|
45
|
+
@setup_index[:push] = @push_dirs.length
|
|
46
|
+
@setup_index[:collapse] = @collapse_dirs.length
|
|
47
|
+
do_setup(new_push, new_collapse) unless new_push.empty? && new_collapse.empty?
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Initialises the shared dir state. Call from subclass +initialize+.
|
|
54
|
+
def init_dirs
|
|
55
|
+
@push_dirs = []
|
|
56
|
+
@collapse_dirs = []
|
|
57
|
+
@ignore_dirs = []
|
|
58
|
+
@setup_index = { push: 0, collapse: 0 }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Template method: subclasses implement this to perform actual registration.
|
|
62
|
+
def do_setup(_new_push, _new_collapse)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|