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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45cabb7d61d5029a494703a95c1b974f1af7ee1286001d457ea7aaf8b870496c
4
- data.tar.gz: fb14ec2556787d19618ee3599685a06a06a4d1ed40a3113c47f61420f84fb202
3
+ metadata.gz: 78eb85866eb8a42cb597f88faeace9f97fb81c8312748767669f776fbb09f504
4
+ data.tar.gz: 78b358fa881a3c0e440d52c67e479c70d29b4d6a4ae79a9eebaa0872e5e92605
5
5
  SHA512:
6
- metadata.gz: 8e11bee2fd442bc29b537d4e7099e4380b19bed851f4f40bd42e119c35f139ee680866bd0f6189334e46652881c224559e10c6ab4dcf722c4d50c455fccdf16e
7
- data.tar.gz: 0c69bf3a342d8fd502d6e64d085edfa9d1a2864b60d8f3f683addc680080f5762246f9901926c81667c40ddad82fdae7ead6e939ea3f297c0f907c983e21622f
6
+ metadata.gz: ae1b0a336dfbaa4f01fccb376a38b2c7020fccae033eeabcad328f20c2fdb312c8bbc14a37eea771b518c180125d6f8224cb3516e4d837e5484dc60a6eb307de
7
+ data.tar.gz: 1b31b4d1a59d2213617b522f3ab88f88a2a3c7ef686ce817fb7bb9e56a2680c47bfa597f7dae7385dbddc35c1a2594911c5a18aa067910604525b98df1a79c10
data/AGENTS.md ADDED
@@ -0,0 +1,24 @@
1
+ # Agents
2
+
3
+ Guidelines for AI agents working on this codebase.
4
+
5
+ - Write tests for new behaviour; run existing tests before committing.
6
+ - Only run tests relevant to your changes — not the full suite every time.
7
+ - If you only changed an example, only run that example's tests.
8
+ - Run targeted tests as you go for fast feedback; run the full suite once before committing.
9
+ - Whenever adding new features/functionality, update complex example with support for it (if appropriate). Also add to or update E2E tests.
10
+ - Update relevant documentation (README, USAGE, ARCHITECTURE, TODO, example READMEs, CHANGELOG).
11
+ - Keep README concise — detailed usage belongs in USAGE.md.
12
+ - Keep documentation minimal — don't be wordy.
13
+ - Consistent style with existing code and docs.
14
+ - Commit as you go with descriptive messages.
15
+ - Don't over-engineer — keep it simple.
16
+ - When multiple implementations or design choices exist, present a menu to the user with your recommendation.
17
+ - Run `RUBY_BOX=1 bundle exec rake` to run all tests (unit, e2e, examples).
18
+ - Run `RUBY_BOX=1 bundle exec rake test` for unit tests only.
19
+ - Run `RUBY_BOX=1 bundle exec rake test e2e` for unit and e2e tests.
20
+ - Run `RUBY_BOX=1 bundle exec rake example:minimal` for a specific example.
21
+ - Run `bundle exec rake format` to format code after every change.
22
+ - Fix any warnings.
23
+ - Use sub-agents where appropriate.
24
+ - Never bump version or publish gem.
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,264 @@
1
+ # Architecture
2
+
3
+ This document describes how Boxwerk works internally.
4
+
5
+ ## Overview
6
+
7
+ Boxwerk enforces package boundaries at runtime using Ruby::Box isolation. Each package gets its own `Ruby::Box` instance. Constants are resolved lazily on first access and cached. Only direct dependencies are accessible; transitive dependencies are blocked.
8
+
9
+ ```
10
+ ┌─────────────────────────────────────────────────────┐
11
+ │ Root Box │
12
+ │ (Bundler + global gems loaded here) │
13
+ │ │
14
+ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
15
+ │ │ root pkg │ │ finance │ │ util │ ... │
16
+ │ │ (box) │ │ (box) │ │ (box) │ │
17
+ │ └──────────┘ └──────────┘ └──────────┘ │
18
+ │ │ │ │ │
19
+ │ │ const_missing autoload │
20
+ │ │ searches deps resolves files │
21
+ └─────────────────────────────────────────────────────┘
22
+ ```
23
+
24
+ ## Ruby::Box Primer
25
+
26
+ [`Ruby::Box`](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html) provides in-process isolation of classes, modules, and constants. Boxwerk relies on these specific behaviours:
27
+
28
+ ### Box Types
29
+
30
+ - **Root box** — A single box per Ruby process. Created during bootstrap. All builtin classes/modules live here. The source for copy-on-write when creating user boxes.
31
+ - **Main box** — A user box automatically created at bootstrap, copied from the root box. The user's main program runs here.
32
+ - **User boxes** — Created with `Ruby::Box.new`, copied from the root box. All user boxes are flat (no nesting). This is what Boxwerk creates for each package.
33
+
34
+ ### Key Behaviours
35
+
36
+ - **File scope.** One `.rb` file runs in a single box. Methods and procs defined in that file always execute in that file's box, even when called from another box.
37
+ - **Top-level constants.** Constants defined at the top level are constants of `Object` within that box. From outside, `box::Foo` accesses them.
38
+ - **Monkey patch isolation.** Reopened built-in classes are visible only within the box that defined them.
39
+ - **Global variable isolation.** `$LOAD_PATH`, `$LOADED_FEATURES`, and other globals are isolated per box. `$LOAD_PATH` and `$LOADED_FEATURES` use the *loading box* (not current box) for `require` resolution.
40
+ - **Copy-on-write.** `Ruby::Box.new` copies the root box's class extensions. Anything loaded into the root box *before* creating a user box is inherited. Anything loaded *after* is not.
41
+ - **`box.eval(code)`** — Evaluates Ruby code in the box's context, like loading a file.
42
+ - **`box.require(path)`** — Requires a file in the box's context. Subsequent requires from that file also run in the same box.
43
+
44
+ ### Important Implications
45
+
46
+ 1. **Order matters.** Gems loaded into the root box via `Bundler.require` before `Ruby::Box.new` are inherited by all user boxes. This is how Boxwerk provides "global gems".
47
+ 2. **No cross-box method inheritance.** A `const_missing` hook defined on `Module` in one box does not fire in another box. Boxwerk must install resolvers per-box.
48
+ 3. **`$LOAD_PATH` per box.** Each box has its own `$LOAD_PATH`, which enables per-package gem version isolation.
49
+
50
+ ## Boot Sequence
51
+
52
+ The `boxwerk` executable (`exe/boxwerk`) orchestrates the boot:
53
+
54
+ ```
55
+ 1. exe/boxwerk starts in the main box
56
+ 2. If Bundler is loaded (bundle exec or binstub), re-exec into a clean
57
+ Ruby process using Bundler.with_unbundled_env (prevents double gem loading)
58
+ 3. For install/info/help/version: load boxwerk directly (no Ruby::Box needed)
59
+ 4. For exec/run/console: check Ruby::Box availability and enabled status
60
+ 5. Switch to root box via Ruby::Box.root.eval(...)
61
+ 6. Load boxwerk gem into root box ($LOAD_PATH.unshift)
62
+ 7. Discover project Gemfile (gems.rb preferred, then Gemfile)
63
+ 8. Run Bundler.setup + Bundler.require in root box
64
+ → All global gems now available in root box
65
+ 9. Call Boxwerk::CLI.run(ARGV)
66
+ → CLI delegates to Setup.run for package boot
67
+ → Discovers boxwerk.yml for configuration (package_paths)
68
+ → Runs global boot (global/ require + global/boot.rb)
69
+ → Eager-loads Zeitwerk constants for child box inheritance
70
+ ```
71
+
72
+ ### Setup.run
73
+
74
+ ```
75
+ 1. Find root package.yml or boxwerk.yml (walk up from current directory)
76
+ 2. Run global boot (if global/ exists):
77
+ a. Require global/ files in root box (eager, not autoload)
78
+ b. Require global/boot.rb in root box
79
+ 3. Eager-load all Zeitwerk-managed constants in root box
80
+ 4. Create PackageResolver — discovers all package.yml files
81
+ → Respects package_paths from boxwerk.yml
82
+ → Creates implicit root package if no root package.yml exists
83
+ 5. Create BoxManager — manages Ruby::Box instances
84
+ 6. Boot all packages in topological order (dependencies first)
85
+ → Root package boot.rb runs in root package box (not root box)
86
+ ```
87
+
88
+ ### BoxManager.boot (per package)
89
+
90
+ For each package, in dependency order:
91
+
92
+ ```
93
+ 1. Create Ruby::Box.new (copied from root box, inherits global gems)
94
+ 2. Setup per-package gem load paths (if Gemfile exists)
95
+ → Prepend gem load paths to the box's $LOAD_PATH
96
+ 3. Auto-require gems from Gemfile (non-root packages only)
97
+ → Respects require: false and require: 'custom/path'
98
+ 4. Scan directories with Zeitwerk (file discovery + inflection)
99
+ → Uses Zeitwerk::Loader::FileSystem for scanning
100
+ → Uses Zeitwerk::Inflector for snake_case → CamelCase conversion
101
+ 5. Register autoload entries in the box via box.eval
102
+ → Implicit namespaces: create Module.new
103
+ → Explicit namespaces: autoload + eager trigger
104
+ → Files: autoload :Invoice, "/path/to/public/invoice.rb"
105
+ 6. Run per-package boot.rb (if it exists)
106
+ → Executes after the package's own constants are scanned
107
+ → Used for configuring additional autoload dirs and collapse dirs
108
+ 7. Wire dependency constants via const_missing
109
+ → Install a resolver that searches direct dependency boxes
110
+ → When enforce_dependencies is false, searches ALL packages:
111
+ explicit deps first (in declared order), then remaining packages
112
+ ```
113
+
114
+ ## Constant Resolution
115
+
116
+ Constants are resolved through two mechanisms:
117
+
118
+ ### Intra-Package: autoload
119
+
120
+ Each package's own constants are registered as `autoload` entries in its box. When code inside the box references `Calculator`, Ruby's built-in `autoload` loads the file and defines the constant — standard Ruby behaviour.
121
+
122
+ ### Cross-Package: const_missing
123
+
124
+ When a constant is not found in the current box (no autoload entry), `Object.const_missing` fires. Boxwerk installs a custom handler per-box that:
125
+
126
+ 1. Iterates through the package's declared direct dependencies
127
+ 2. For each dependency, checks if it has the constant (via file index or `const_get`)
128
+ 3. Enforces privacy rules (public path, private_constants list)
129
+ 4. Returns the constant value from the dependency's box
130
+ 5. Raises `NameError` if no dependency has the constant
131
+
132
+ ```ruby
133
+ # Simplified const_missing flow:
134
+ class Object
135
+ def self.const_missing(const_name)
136
+ deps.each do |dep|
137
+ next unless dep.has_constant?(const_name)
138
+ check_privacy!(const_name, dep)
139
+ return dep.box.const_get(const_name)
140
+ end
141
+ raise NameError, "uninitialized constant #{const_name}"
142
+ end
143
+ end
144
+ ```
145
+
146
+ Constants are **not** wrapped in namespaces. `Invoice` is accessible as `Invoice`, not `Finance::Invoice`. This matches how constants work within a single Ruby application.
147
+
148
+ ### Root Box Resolver (for exec/run)
149
+
150
+ When running commands via `boxwerk exec` or `boxwerk run`, some tools (like Rake) load files via the root box rather than the package box. Boxwerk installs a composite resolver on `Ruby::Box.root` that:
151
+
152
+ 1. Tries the target package's own box first (for internal constants)
153
+ 2. Falls through to the target box's dependency resolver
154
+ 3. This ensures package constants are accessible even when code is loaded by tools running in the root box
155
+
156
+ ## Package Resolution
157
+
158
+ `PackageResolver` discovers packages by scanning for `package.yml` files:
159
+
160
+ 1. Start from the project root
161
+ 2. Glob for `package.yml` files using `package_paths` from `boxwerk.yml` (default: `**/`)
162
+ 3. Parse each YAML file into a `Package` object
163
+ 4. If no root `package.yml` exists, create an implicit root package with `enforce_dependencies: false`, `enforce_privacy: false`, and automatic dependencies on all discovered packages
164
+ 5. Resolve dependency order via DFS — circular dependencies are allowed (back-edges are skipped)
165
+ 6. Provide topological ordering for boot (dependencies before dependents)
166
+
167
+ ### package.yml Format
168
+
169
+ ```yaml
170
+ enforce_dependencies: true
171
+ dependencies:
172
+ - packs/util
173
+ - packs/billing
174
+ enforce_privacy: true
175
+ public_path: public/ # default
176
+ private_constants:
177
+ - "::InternalHelper"
178
+ ```
179
+
180
+ This is the standard [Packwerk](https://github.com/Shopify/packwerk) format.
181
+
182
+ ## Privacy Enforcement
183
+
184
+ `PrivacyChecker` enforces which constants a package exposes:
185
+
186
+ - **public_path** (default: `public/`) — Files in this directory define the package's public API. Only these constants are accessible to dependents.
187
+ - **private_constants** — Explicitly private constants, blocked even if in the public path.
188
+ - **pack_public sigil** — Files outside the public path can be marked public with `# pack_public: true` in the first 5 lines.
189
+
190
+ Privacy is checked at constant resolution time in the `const_missing` handler. A `NameError` with a descriptive message is raised for violations.
191
+
192
+ ## Per-Package Gem Isolation
193
+
194
+ `GemResolver` enables different packages to use different versions of the same gem:
195
+
196
+ 1. Check if the package has a `gems.rb` or `Gemfile` (and corresponding lockfile)
197
+ 2. Parse the lockfile with `Bundler::LockfileParser` to get gem specs
198
+ 3. Parse the Gemfile with `GemfileRequireParser` to extract autorequire directives
199
+ 4. Find the actual gem installation paths by searching all `Gem.path` directories
200
+ 5. Collect full require paths for each gem and its runtime dependencies
201
+ 6. Prepend these paths to the box's `$LOAD_PATH`
202
+ 7. Auto-require gems declared in the Gemfile (non-root packages only):
203
+ - Default (`gem 'faker'`) → `require 'faker'`
204
+ - Custom (`gem 'foo', require: 'foo/bar'`) → `require 'foo/bar'`
205
+ - Disabled (`gem 'dotenv', require: false`) → skip
206
+
207
+ Since each box has its own `$LOAD_PATH`, `require 'faker'` in two different boxes can load different versions.
208
+
209
+ ## File-to-Constant Mapping
210
+
211
+ Boxwerk uses Zeitwerk's inflector for file-to-constant name mapping:
212
+
213
+ ```
214
+ lib/calculator.rb → Calculator
215
+ lib/tax_calculator.rb → TaxCalculator
216
+ public/invoice.rb → Invoice
217
+ lib/api/v2/client.rb → Api::V2::Client
218
+ ```
219
+
220
+ Zeitwerk cannot register autoloads directly inside `Ruby::Box` because autoload calls execute in the box where the code was defined (root box for Zeitwerk). Boxwerk works around this by using Zeitwerk only for file scanning and inflection, then registering autoloads via `box.eval`. For nested constants, implicit namespaces are created as `Module.new` instances, and explicit namespaces (with a matching `.rb` file) are eagerly loaded before child autoloads are registered.
221
+
222
+ ## CLI Commands
223
+
224
+ The CLI (`Boxwerk::CLI`) provides:
225
+
226
+ | Command | Description |
227
+ |---------|-------------|
228
+ | `exec` | Run a command (gem binstub) in the boxed environment |
229
+ | `run` | Run a Ruby script in a package box |
230
+ | `console` | Start an IRB console in a package box |
231
+ | `info` | Show package structure and dependencies |
232
+ | `install` | Bundle install for all packages |
233
+
234
+ ### console Implementation
235
+
236
+ Console always runs IRB in `Ruby::Box.root` rather than the target package box. The composite resolver installed on root provides the same constant access. This avoids a Ruby 4.0.1 GC bug where running IRB in child boxes with `const_missing` overrides triggers a double-free crash during process exit.
237
+
238
+ ### exec Implementation
239
+
240
+ `exec` resolves the command to a gem binstub path, then evaluates the binstub's content in the target box using `box.eval(content)`. File content is evaluated directly (not via `load`) because `load` creates a new file scope where inherited DSL methods (e.g. Rake's `task`) may not be visible inside a `Ruby::Box`.
241
+
242
+ ### --all Flag
243
+
244
+ The `--all` flag runs `exec` for each package in a separate subprocess. This is necessary because test frameworks like Minitest register tests globally via `at_exit`, which would conflict across packages in a single process.
245
+
246
+ ### --global Flag
247
+
248
+ The `--global` / `-g` flag runs commands directly in `Ruby::Box.root`, bypassing package resolution entirely. No package constants are accessible — only global gems. Useful for debugging.
249
+
250
+ ## Module Structure
251
+
252
+ ```
253
+ Boxwerk
254
+ ├── CLI # Command-line interface
255
+ ├── Setup # Boot orchestration (find root, create resolver + manager)
256
+ ├── PackageResolver # Discover packages, validate deps, topological sort
257
+ ├── Package # Data class for a single package
258
+ ├── BoxManager # Create boxes, scan with Zeitwerk, wire constants
259
+ ├── ConstantResolver # Install const_missing handlers per-box
260
+ ├── PrivacyChecker # Check public/private constant access
261
+ ├── GemResolver # Resolve per-package gem load paths and autorequire
262
+ ├── GemfileRequireParser # Lightweight Gemfile parser for require directives
263
+ └── ZeitwerkScanner # Zeitwerk-based file scanning and autoload registration
264
+ ```
data/CHANGELOG.md CHANGED
@@ -1,8 +1,79 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.0] - 2026-01-05
3
+ ## [v0.3.0] 2026-03-02
4
+
5
+ Complete architecture rewrite. Each package now runs in its own `Ruby::Box`
6
+ with constants resolved lazily at runtime via `const_missing`. Reads standard
7
+ Packwerk `package.yml` files without requiring Packwerk.
8
+
9
+ ### Added
10
+
11
+ - **Package isolation** — each package runs in its own `Ruby::Box`; constants
12
+ from undeclared dependencies and private constants raise `NameError` at
13
+ runtime.
14
+ - **Per-package gems** — packages can declare their own `gems.rb`/`Gemfile`
15
+ with independent gem versions; auto-require mirrors Bundler's default
16
+ behaviour (respects `require: false`, `require: 'path'`).
17
+ - **Zeitwerk autoloading** — constants discovered via Zeitwerk conventions;
18
+ default autoload roots: `lib/` and `public/`.
19
+ - **Privacy enforcement** — `enforce_privacy`, `public_path`,
20
+ `private_constants`, and `# pack_public: true` file sigil.
21
+ - **`Boxwerk.package`** — returns a `PackageContext` in per-package `boot.rb`
22
+ with `name`, `root?`, `config`, `root_path`, and `autoloader`.
23
+ - **`Boxwerk.global`** — returns a `GlobalContext` from any box context.
24
+ - **Autoloader API** — `push_dir`, `collapse`, `ignore`, `setup` on both
25
+ `PackageContext::Autoloader` and `GlobalContext::Autoloader`; shared via
26
+ `AutoloaderMixin`. `push_dir` and `collapse` auto-call `setup` so constants
27
+ are available immediately in boot scripts.
28
+ - **`global/boot.rb`** — runs in the root box before package boxes; shared
29
+ constants defined here are inherited by all packages.
30
+ - **`eager_load_global`** / **`eager_load_packages`** boxwerk.yml options.
31
+ - **Package name normalization** — leading `./` and trailing `/` stripped;
32
+ `packs/foo`, `./packs/foo`, and `packs/foo/` are equivalent.
33
+ - **CLI commands** — `exec`, `run`, `console`, `install`, `info`.
34
+ - **`boxwerk install`** — installs gems for all packages; works on a fresh
35
+ clone without pre-installed gems.
36
+ - **`boxwerk info`** — shows config, global context, and per-package
37
+ autoload/collapse/ignore dirs (with eager-load status), enforcements,
38
+ dependencies, gems, and boot script presence. Boots the application
39
+ (`RUBY_BOX=1` required).
40
+ - **NameError hints** — improved error messages like
41
+ `(defined in 'packs/baz', not a dependency of '.')` in child package
42
+ contexts.
43
+ - **Circular dependency detection** in `boxwerk info` tree output.
44
+ - **`collapse` / `ignore` in boot.rb** — collapses intermediate namespaces
45
+ (e.g. `Analytics::Formatters` → `Analytics::*`); ignores dirs from
46
+ autoloading.
47
+ - **Missing lockfile warning** — graceful message directing to
48
+ `boxwerk install` when a Gemfile exists but no lockfile is found.
49
+ - **Monkey patch isolation** — patches defined in one package box are not
50
+ visible in other packages or the root context.
51
+ - **`examples/complex`** and **`examples/minimal`** demonstrating all features.
52
+ - **E2E test suite** (74 tests) alongside unit tests (120 tests).
53
+ - **GitHub Pages API documentation** published from `lib/` via RDoc.
54
+
55
+ ### Removed
56
+
57
+ - Custom file-to-constant mapping (`Boxwerk.camelize`), replaced by Zeitwerk.
58
+ - Namespace wrapping.
59
+
60
+ ## [v0.2.0] - 2026-01-06
61
+
62
+ ### Changed
63
+ - Simplified implementation (~370 lines removed)
64
+ - Consolidated cycle detection in Graph (removed redundant methods)
65
+ - Added class-level documentation to all modules
66
+ - Simplified example application
67
+
68
+ ### Removed
69
+ - Removed `Gemfile.lock` from git (library best practice)
70
+ - Removed `sig/boxwerk.rbs`
71
+
72
+ ## [v0.1.0] - 2026-01-05
4
73
 
5
74
  Initial release.
6
75
 
7
- [unreleased]: https://github.com/dtcristo/boxwerk/compare/v0.1.0...HEAD
8
- [0.1.0]: https://github.com/dtcristo/boxwerk/releases/tag/v0.1.0
76
+ [Unreleased]: https://github.com/dtcristo/boxwerk/compare/v0.3.0...HEAD
77
+ [v0.3.0]: https://github.com/dtcristo/boxwerk/releases/tag/v0.3.0
78
+ [v0.2.0]: https://github.com/dtcristo/boxwerk/releases/tag/v0.2.0
79
+ [v0.1.0]: https://github.com/dtcristo/boxwerk/releases/tag/v0.1.0