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/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
|
-
|
|
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
|
|
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]
|
data/TODO.md
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# TODO
|
|
2
|
+
|
|
3
|
+
Planned improvements for Boxwerk, ordered by priority.
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
| # | Item | Priority | Status |
|
|
8
|
+
|---|------|----------|--------|
|
|
9
|
+
| 7 | `boxwerk-rails` gem | Medium | Future |
|
|
10
|
+
| 8 | Fix `rails console` crash | Medium | Not started |
|
|
11
|
+
| 14 | Constant reloading (dev workflow) | Medium | Not started |
|
|
12
|
+
| 15 | IRB console autocomplete | Medium | Not started |
|
|
13
|
+
| 16 | `boxwerk init` (scaffold packages) | Low | Not started |
|
|
14
|
+
| 17 | Sorbet support | Low | Future |
|
|
15
|
+
| 18 | Per-package testing improvements | Low | Not started |
|
|
16
|
+
| 19 | Additional CLI commands | Low | Not started |
|
|
17
|
+
| 20 | IDE / language server support | Low | Future |
|
|
18
|
+
| 21 | Bundler inside package boxes | Low | Investigated — current approach preferred |
|
|
19
|
+
| 22 | RUBYOPT bootstrap (multi-phase) | Medium | Future |
|
|
20
|
+
| 23 | Native Zeitwerk autoloaders in resolution | Medium | Investigated — partially feasible |
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 7. `boxwerk-rails` Gem
|
|
25
|
+
|
|
26
|
+
**Priority: Medium — Future**
|
|
27
|
+
|
|
28
|
+
A companion gem that automatically configures Rails for Boxwerk. Would
|
|
29
|
+
eliminate manual setup in `global/boot.rb` and `bin/rails`.
|
|
30
|
+
|
|
31
|
+
### Scope
|
|
32
|
+
|
|
33
|
+
- Auto-configure `config.autoload_paths = []` and `config.eager_load_paths = []`
|
|
34
|
+
- Create a `bin/rails` binstub compatible with `boxwerk exec`
|
|
35
|
+
- Pre-require and eager-load Rails frameworks in global boot
|
|
36
|
+
- Aggregate migration paths from packages (`packs/*/db/migrate/`)
|
|
37
|
+
- Package-aware Rails generators
|
|
38
|
+
|
|
39
|
+
### Prerequisites
|
|
40
|
+
|
|
41
|
+
- Per-package gem auto-require — needed for clean gem loading ✅
|
|
42
|
+
- `Boxwerk.package` API — needed for package-aware generators ✅
|
|
43
|
+
- Stable Boxwerk API
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 8. Fix `rails console` Crash
|
|
48
|
+
|
|
49
|
+
**Priority: Medium**
|
|
50
|
+
|
|
51
|
+
`boxwerk exec rails console` crashes when you type input. This is likely due to IRB/readline interaction with Ruby::Box context. The workaround is to use `boxwerk console` instead, which properly handles the console environment.
|
|
52
|
+
|
|
53
|
+
### Implementation Plan
|
|
54
|
+
|
|
55
|
+
1. **Investigate** — Reproduce the crash, check if it's a readline/reline issue in box context.
|
|
56
|
+
2. **If fixable** — Fix the interaction between `rails/commands` console mode and box eval.
|
|
57
|
+
3. **If not easily fixable** — Document `boxwerk console` as the recommended interactive console. Rails console docs already updated in `examples/rails/README.md`.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 14. Constant Reloading
|
|
62
|
+
|
|
63
|
+
**Priority: Medium**
|
|
64
|
+
|
|
65
|
+
Constants loaded into a box are permanent. Development requires restarting
|
|
66
|
+
the process after code changes.
|
|
67
|
+
|
|
68
|
+
### Plan
|
|
69
|
+
|
|
70
|
+
**Approach 1: Box recreation (recommended)**
|
|
71
|
+
- Watch for file changes (`listen` gem or `rb-fsevent`)
|
|
72
|
+
- When a file changes, identify the owning package
|
|
73
|
+
- Recreate that package's box and all dependent boxes
|
|
74
|
+
- Re-wire dependency constants
|
|
75
|
+
|
|
76
|
+
Optimizations: only recreate the affected subgraph, cache unchanged indexes,
|
|
77
|
+
use checksums to skip touch-only saves.
|
|
78
|
+
|
|
79
|
+
**Approach 2: Ruby::Box API**
|
|
80
|
+
- A future `Box#reload` or `Box#remove_const` API would be ideal
|
|
81
|
+
|
|
82
|
+
Start with Approach 1 behind an opt-in flag (`boxwerk run --watch`).
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 15. IRB Console Autocomplete
|
|
87
|
+
|
|
88
|
+
**Priority: Medium**
|
|
89
|
+
|
|
90
|
+
`boxwerk console` runs with `--noautocomplete` because IRB's completer uses
|
|
91
|
+
`Module.constants` which doesn't reflect box-scoped constants.
|
|
92
|
+
|
|
93
|
+
Console runs in `Ruby::Box.root` with a composite resolver (workaround for a
|
|
94
|
+
Ruby 4.0.1 GC crash in child boxes). Revisit when Ruby::Box stabilizes.
|
|
95
|
+
|
|
96
|
+
### Plan
|
|
97
|
+
|
|
98
|
+
1. Implement `Boxwerk.available_constants(box)` — returns own + dependency
|
|
99
|
+
constants for a box
|
|
100
|
+
2. Create a custom IRB completion proc querying the Boxwerk constant index
|
|
101
|
+
3. Register via `IRB::Completion` API
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 16. `boxwerk init`
|
|
106
|
+
|
|
107
|
+
**Priority: Low**
|
|
108
|
+
|
|
109
|
+
Scaffold a new package with `package.yml`, `lib/`, `public/`, and `test/`.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 17. Sorbet Support
|
|
114
|
+
|
|
115
|
+
**Priority: Low — Future**
|
|
116
|
+
|
|
117
|
+
Enable Sorbet type-checking across Boxwerk package boundaries. Types defined
|
|
118
|
+
in one package should be visible to dependents according to the same rules as
|
|
119
|
+
runtime constants.
|
|
120
|
+
|
|
121
|
+
### Possible Approaches
|
|
122
|
+
|
|
123
|
+
- **Custom Tapioca Compiler** — Generate RBI files per package that reflect
|
|
124
|
+
the dependency graph. Only expose types from declared dependencies.
|
|
125
|
+
- **Plugin gem** (`boxwerk-sorbet` or `sorbet-boxwerk`) — Integrate with
|
|
126
|
+
Sorbet's plugin system to enforce package boundaries at type-check time.
|
|
127
|
+
- **RBI generation from file indexes** — Use Boxwerk's existing file index
|
|
128
|
+
and privacy checker to generate per-package RBI files.
|
|
129
|
+
|
|
130
|
+
### Challenges
|
|
131
|
+
|
|
132
|
+
- Sorbet expects all constants in a flat namespace; Boxwerk's box isolation
|
|
133
|
+
is invisible to the type checker
|
|
134
|
+
- Need to map Boxwerk's runtime `const_missing` resolution to static types
|
|
135
|
+
- Privacy enforcement must work at both type-check and runtime
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 18. Per-Package Testing Improvements
|
|
140
|
+
|
|
141
|
+
**Priority: Low**
|
|
142
|
+
|
|
143
|
+
Tests currently run via `boxwerk exec --all rake test` with subprocess
|
|
144
|
+
isolation.
|
|
145
|
+
|
|
146
|
+
### Possible Improvements
|
|
147
|
+
|
|
148
|
+
- **Parallel execution** — run package tests in parallel for faster CI
|
|
149
|
+
- **Coverage aggregation** — merge coverage reports across packages
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 19. Additional CLI Commands
|
|
154
|
+
|
|
155
|
+
**Priority: Low**
|
|
156
|
+
|
|
157
|
+
- **`boxwerk outdated`** — check for outdated per-package gems
|
|
158
|
+
- **`boxwerk update [package]`** — update lockfiles in topological order
|
|
159
|
+
- **`boxwerk clean`** — remove unused lockfiles and empty directories
|
|
160
|
+
- **`boxwerk list`** — display packages with gem versions
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 20. IDE / Language Server Support
|
|
165
|
+
|
|
166
|
+
**Priority: Low — Future**
|
|
167
|
+
|
|
168
|
+
- Language servers aware of package boundaries
|
|
169
|
+
- Autocomplete filtered to accessible constants only
|
|
170
|
+
- Go-to-definition across package boundaries (respecting privacy)
|
|
171
|
+
- Real-time privacy violation highlighting
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## 21. Bundler Inside Package Boxes
|
|
176
|
+
|
|
177
|
+
**Priority: Low — Current lockfile-parsing approach works well**
|
|
178
|
+
|
|
179
|
+
**Status: Investigated; current approach preferred over native Bundler**
|
|
180
|
+
|
|
181
|
+
### Investigation Results
|
|
182
|
+
|
|
183
|
+
Two approaches were tested for native `Bundler.setup` inside child boxes:
|
|
184
|
+
|
|
185
|
+
#### Approach 1: Box-Local Monkey-Patch
|
|
186
|
+
|
|
187
|
+
The original plan was to eval Bundler patches inside child boxes. However, `require 'bundler'` inside a child box loads Bundler's code into that box, but Bundler internally calls back into root-box code (e.g., `Gem` activation, `$LOAD_PATH` modification). **Bundler's code architecture makes surgical patching impractical** — too many internal methods would need overriding.
|
|
188
|
+
|
|
189
|
+
#### Approach 2: Capture-and-Apply
|
|
190
|
+
|
|
191
|
+
Run `Bundler.setup` in the root box with the package's gemfile, capture the `$LOAD_PATH` delta, restore root state, and apply the delta to the child box. This was tested and **works**:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# Capture new paths from Bundler.setup
|
|
195
|
+
before = $LOAD_PATH.dup
|
|
196
|
+
ENV['BUNDLE_GEMFILE'] = pkg_gemfile
|
|
197
|
+
Bundler.reset!
|
|
198
|
+
Bundler.setup
|
|
199
|
+
new_paths = $LOAD_PATH - before
|
|
200
|
+
$LOAD_PATH.replace(before) # restore
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
However, this approach has significant drawbacks:
|
|
204
|
+
- **Temporarily mutates root `$LOAD_PATH`** — requires careful save/restore
|
|
205
|
+
- **`Bundler.reset!` has side effects** — global Bundler state is modified
|
|
206
|
+
- **Serial execution required** — can't parallelize Bundler.setup calls
|
|
207
|
+
- **GC crashes** — Ruby::Box + Bundler interaction triggers known Ruby 4.0.1 GC bug ("pointer being freed was not allocated") on exit
|
|
208
|
+
- **No clear advantage** — produces the same result as lockfile parsing
|
|
209
|
+
|
|
210
|
+
### Conclusion
|
|
211
|
+
|
|
212
|
+
The current `GemResolver` lockfile-parsing approach is **simpler, more reliable, and produces identical results**. It parses `gems.locked`/`Gemfile.lock` with `Bundler::LockfileParser`, resolves gem specs via `Gem.path`, and collects load paths recursively. This avoids all Bundler runtime side effects.
|
|
213
|
+
|
|
214
|
+
Native `Bundler.setup` integration should only be revisited if:
|
|
215
|
+
- Ruby::Box gains native Bundler support
|
|
216
|
+
- The GC crash bug is fixed
|
|
217
|
+
- A use case emerges that lockfile parsing can't handle
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 22. RUBYOPT Bootstrap (`-rboxwerk/setup`)
|
|
222
|
+
|
|
223
|
+
**Priority: Medium — Future**
|
|
224
|
+
|
|
225
|
+
**Status: Previously blocked; revisited for box-local monkey-patching**
|
|
226
|
+
|
|
227
|
+
Using `RUBYOPT=-rboxwerk/setup` to automatically bootstrap hits obstacles with gem loading and Zeitwerk handling. Box-local monkey-patching can help:
|
|
228
|
+
|
|
229
|
+
### Box-Local Monkey-Patch Approach
|
|
230
|
+
|
|
231
|
+
When RUBYOPT loads `boxwerk/setup`:
|
|
232
|
+
|
|
233
|
+
1. **Bootstrap minimal setup** — Detect Ruby::Box, activate it, call `Boxwerk::Setup.run` from root box to boot all packages
|
|
234
|
+
2. **Bundler patches in package boxes** — When package boxes are created, inject Bundler patches (from item 21) so native gem loading works
|
|
235
|
+
3. **Zeitwerk patches in package boxes** — Inject Zeitwerk patches so `Kernel.require` and autoload work in correct box context
|
|
236
|
+
|
|
237
|
+
This is feasible because patches are box-local and don't interfere with each other.
|
|
238
|
+
|
|
239
|
+
### Implementation Plan
|
|
240
|
+
|
|
241
|
+
1. **Create `lib/boxwerk/setup.rb`** (RUBYOPT entry point) — Minimal bootstrap that:
|
|
242
|
+
- Checks Ruby::Box availability
|
|
243
|
+
- Switches to root box
|
|
244
|
+
- Requires Boxwerk lib
|
|
245
|
+
- Calls `Boxwerk::Setup.run`
|
|
246
|
+
2. **Reuse item 21 patches** — Bundler patches from item 21 are automatically applied in package boxes during boot
|
|
247
|
+
3. **Create `lib/boxwerk/patches/zeitwerk.rb`** — Monkey patches for Zeitwerk if needed (may be unnecessary if Bundler alone works)
|
|
248
|
+
4. **Test** — RUBYOPT bootstrap with both global and per-package gems
|
|
249
|
+
5. **Handle edge cases** — Double-loading prevention, boot order, gem requiring
|
|
250
|
+
|
|
251
|
+
### Challenges
|
|
252
|
+
|
|
253
|
+
- Complexity of initial RUBYOPT bootstrap (but simpler if Bundler patches from item 21 work)
|
|
254
|
+
- Ensuring all gems load correctly without being loaded twice
|
|
255
|
+
- May require additional Zeitwerk patches beyond Bundler
|
|
256
|
+
|
|
257
|
+
Recommend starting with item 21 (Bundler patch). If that works, RUBYOPT becomes straightforward.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## 23. Native Zeitwerk Autoloaders in Constant Resolution
|
|
262
|
+
|
|
263
|
+
**Priority: Medium — Future**
|
|
264
|
+
|
|
265
|
+
**Status: Investigated; partially feasible with workaround for implicit namespaces**
|
|
266
|
+
|
|
267
|
+
Currently, Boxwerk uses Zeitwerk only for file scanning and inflection. Autoloads are registered via `box.eval("autoload :Foo, '/path'")` (manual). Native Zeitwerk autoloaders per box were tested.
|
|
268
|
+
|
|
269
|
+
### Investigation Results
|
|
270
|
+
|
|
271
|
+
#### What Works
|
|
272
|
+
|
|
273
|
+
Native `Zeitwerk::Loader` instances **work inside child boxes** for packages with real files:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
box.eval("$LOAD_PATH.unshift('#{zeitwerk_path}')")
|
|
277
|
+
box.eval("require 'zeitwerk'")
|
|
278
|
+
box.eval(<<~CODE)
|
|
279
|
+
loader = Zeitwerk::Loader.new
|
|
280
|
+
loader.push_dir('#{pkg_dir}', namespace: Object)
|
|
281
|
+
loader.setup
|
|
282
|
+
CODE
|
|
283
|
+
box.eval('Orders::Order.name') # => "Orders::Order" ✅
|
|
284
|
+
box.eval('loader.eager_load') # ✅
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Tested and confirmed:
|
|
288
|
+
- ✅ Autoload registration in child box
|
|
289
|
+
- ✅ Nested constant resolution (`Orders::Order`, `Orders::LineItem`)
|
|
290
|
+
- ✅ Eager loading
|
|
291
|
+
- ✅ Isolation (constants don't leak to root box)
|
|
292
|
+
|
|
293
|
+
#### What Doesn't Work: Implicit Namespaces
|
|
294
|
+
|
|
295
|
+
**Implicit namespaces fail.** When a directory exists without a matching `.rb` file (e.g., `kitchen/` without `kitchen.rb`), Zeitwerk registers an autoload pointing to the directory path. When Ruby triggers the autoload, Zeitwerk's `Kernel#require` override normally intercepts it to create the namespace module. However, **Ruby::Box replaces `Kernel#require`** with its own `Ruby::Box::Loader#require`, which doesn't know about Zeitwerk's implicit namespace convention and fails with `LoadError`.
|
|
296
|
+
|
|
297
|
+
**Workaround:** Pre-create implicit namespace modules before Zeitwerk setup:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
box.eval('module Kitchen; end') # pre-create implicit namespace
|
|
301
|
+
box.eval('loader.setup') # now Zeitwerk works ✅
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
This was tested and works, but it means Boxwerk would still need to scan directory structures to identify implicit namespaces — partially defeating the purpose of delegating to Zeitwerk.
|
|
305
|
+
|
|
306
|
+
#### Collapse Dirs
|
|
307
|
+
|
|
308
|
+
Collapse dirs with implicit namespaces also fail for the same reason. They work after pre-creating the namespace module.
|
|
309
|
+
|
|
310
|
+
### Conclusion
|
|
311
|
+
|
|
312
|
+
Native Zeitwerk is **viable but requires a pre-scan step** for implicit namespaces. This makes the integration more complex than originally hoped. The current manual autoload approach already handles implicit namespaces correctly (creates modules via `box.eval("module Foo; end")`) and is simpler.
|
|
313
|
+
|
|
314
|
+
Worth revisiting if:
|
|
315
|
+
- Ruby::Box adds Zeitwerk-aware require handling
|
|
316
|
+
- Ruby::Box exposes a hook for custom autoload resolution
|
|
317
|
+
- Zeitwerk adds a mode that pre-creates implicit namespace modules
|