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.
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]
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