carton 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f3d0c8755a90c3eb40d7ee29a16a174a89f1c678e43ba08275fab4502b298fae
4
+ data.tar.gz: 582512d0d84c6e3dfa2b49c9d95b5e77e4fac4a25a45b63b17bf9ed6087d65c1
5
+ SHA512:
6
+ metadata.gz: f48e9cdd2c5c64d12f323de0acc65498f7e0c0aa7f230decdf5fae538337e268c07686e9f2b5017b4d38dd1b4c22bb2120bc5c54b5ce6884f59d7b319779b0e5
7
+ data.tar.gz: 1470bb044aaacf574535228f1bc9f12a84f0654651c3fbaa52fe52bcde0f215c9262eb0d603c6cc98239c29f4f44854b07534ff608af0bc206e701f2498f1cc0
data/AGENTS.md ADDED
@@ -0,0 +1,47 @@
1
+ # Agents
2
+
3
+ Guidance and memory for agents working on this repo.
4
+
5
+ ## Memory bank
6
+
7
+ - Goal: make Carton feel like part of Ruby: built-in, simple, streamlined, and not verbose.
8
+ - Prefer simpler implementation over complexity.
9
+ - Keep the public API small and Ruby-like.
10
+ - The project should work with or without Bundler.
11
+ - A package in this system is called a carton, though docs can still use "package" in plain English when it reads better.
12
+ - Current reliable bundled-carton pattern: use `Carton.with_bundle(gemfile) { import ... }` for the entry import that should activate a carton-local bundle.
13
+ - Conflicting bundles still need a subprocess workaround today.
14
+ - Ruby::Box boxes start from root-box load paths/loaded features, so Carton must copy parent non-gem load paths forward itself.
15
+ - `$LOAD_PATH` is box-local, but `Gem.loaded_specs` is shared across boxes in practice, so conflicting bundle activation still collides there.
16
+ - Duplicating `Gem.loaded_specs` inside a box isolates a single bundled import from root state, but conflicting bundles still fail because Bundler also rewrites shared RubyGems entrypoints/spec state such as `Gem::Specification.all`.
17
+ - Never push. Make local commits only; the user handles pushes.
18
+
19
+ ## Working rules
20
+
21
+ - Everything should have tests. Write tests for new behaviour, run targeted tests as you go, and `RUBY_BOX=1 bundle exec rake` before finishing.
22
+ - If you only changed an example, run that example during iteration and the full suite before finishing.
23
+ - Run `bundle exec rake format` after every change.
24
+ - Keep implementation and docs concise.
25
+ - Do not over-engineer.
26
+ - Never use thread local variables in implementation.
27
+ - Always review https://docs.ruby-lang.org/en/4.0/Ruby/Box.html on how `Ruby::Box` works.
28
+ - Never bump the version or publish the gem.
29
+ - When multiple implementation choices are viable, present a menu with your recommendation.
30
+ - Commit as you go with descriptive messages and the required Co-authored-by trailer.
31
+ - Never push; the user will do that.
32
+
33
+ ## Docs
34
+
35
+ - Keep `README.md`, `USAGE.md`, `DESIGN.md`, `TODO.md`, `AGENTS.md`, and example READMEs up to date when behavior, workflow, or plans change.
36
+ - Keep the root `README.md` minimal; move detail into focused docs.
37
+ - Put future work, open questions, and upstream ideas in `TODO.md`.
38
+ - Update `AGENTS.md` whenever the user gives durable project guidance or you discover stable context worth remembering.
39
+ - Use `AGENTS.md` as a concise memory bank, not a changelog.
40
+
41
+ ## Commands
42
+
43
+ - `RUBY_BOX=1 bundle exec rake` - full suite
44
+ - `RUBY_BOX=1 bundle exec rake test` - unit tests only
45
+ - `RUBY_BOX=1 bundle exec rake example:minimal` - minimal example
46
+ - `RUBY_BOX=1 bundle exec rake example:complex` - complex example
47
+ - `bundle exec rake format` - format code
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-04-16
6
+
7
+ Initial release.
8
+
9
+ [Unreleased]: https://github.com/dtcristo/carton/compare/v0.1.0...HEAD
10
+ [0.1.0]: https://github.com/dtcristo/carton/releases/tag/v0.1.0
data/DESIGN.md ADDED
@@ -0,0 +1,84 @@
1
+ # Design
2
+
3
+ ## Goal
4
+
5
+ Keep the library small: a thin wrapper around `Ruby::Box` that makes isolated file-to-file imports feel like part of Ruby.
6
+
7
+ ## Main pieces
8
+
9
+ | File | Role |
10
+ | --- | --- |
11
+ | `lib/carton.rb` | Entry point, version guard, `Carton.with_bundle`, and internal wiring |
12
+ | `lib/carton/kernel_patch.rb` | Adds global `import`, `import_relative`, `export_default`, and `export` |
13
+ | `lib/carton/runtime.rb` | Builds boxes, resolves targets, runs imports, and extracts exports |
14
+ | `lib/carton/box.rb` | Box-specific helpers for requiring files, managing export state, and inheriting safe load paths |
15
+ | `lib/carton/exports.rb` | Wraps named exports in a module-like namespace |
16
+ | `lib/carton/export_methods.rb` | Shared `[]`, `fetch`, `fetch_values`, `values_at`, `key?`, and destructuring support |
17
+
18
+ ## Import flow
19
+
20
+ 1. `import` or `import_relative` delegates to `Carton::Runtime.import`.
21
+ 2. A fresh `Carton::Box` is created for that import.
22
+ 3. The carton entrypoint (`lib/carton.rb`) is required inside the box so the box has `export`, `import`, and `import_relative`.
23
+ 4. The target file is resolved either from the caller's base directory or from the current box's `$LOAD_PATH`.
24
+ 5. The target file is required inside the box.
25
+ 6. If the file called `export_default` or `export`, the exported value is returned.
26
+ 7. If the export was a hash, it is wrapped in `Carton::Exports`.
27
+ 8. If there was no export, the box itself is returned.
28
+
29
+ Every import creates a fresh box. There is no module cache at the Carton layer.
30
+
31
+ ## Export model
32
+
33
+ There are three return shapes:
34
+
35
+ - single export: the exported object itself
36
+ - named exports: `Carton::Exports`
37
+ - no export: `Carton::Box`
38
+
39
+ `export_default` is the explicit single-export form. `export` is the named-export form and only accepts keyword arguments. `Carton::Exports` exposes capitalized keys as constants and lowercase keys as singleton methods. Both `Carton::Exports` and `Carton::Box` share the same small fetch/deconstruction API through `ExportMethods`.
40
+
41
+ ## Load path model
42
+
43
+ Ruby::Box creates new user boxes from the root box, not from the current caller box. In `box.c`, each new box starts by duplicating the root box's `load_path` and `loaded_features`, and `require` resolves against the loading box's local `$LOAD_PATH` / `$LOADED_FEATURES`.
44
+
45
+ Carton therefore re-inherits the parent box's non-gem load paths manually so local carton directories can still be resolved by name across nested imports.
46
+
47
+ The current filter deliberately tries to avoid leaking gem paths from a parent import chain into child boxes:
48
+
49
+ - RubyGems paths are filtered
50
+ - common Bundler vendor paths are filtered
51
+ - local, non-gem carton paths are preserved
52
+
53
+ This is why the examples add sibling `lib/` directories to `$LOAD_PATH` explicitly before using `import 'name'`.
54
+
55
+ ## Bundler model
56
+
57
+ The current implementation works best when a carton bundle is selected before its entry file is evaluated. In practice that means selecting `BUNDLE_GEMFILE` around the `import` call for that carton; `Carton.with_bundle` is the small wrapper for that handoff.
58
+
59
+ This keeps one carton-local bundle isolated from the caller, but current limitations remain:
60
+
61
+ - a parent already running under Bundler is not a reliable place to import a child carton with its own bundle
62
+ - switching between conflicting bundles in one process is not reliable today
63
+ - the complex example uses a subprocess for the conflicting `loot` bundle for that reason
64
+
65
+ The problem is not that Bundler has special C code mutating every box at once. Bundler and RubyGems do their activation work in Ruby:
66
+
67
+ - `Bundler::SharedHelpers#find_gemfile` reads `ENV['BUNDLE_GEMFILE']`
68
+ - `Bundler::Runtime#setup` calls `clean_load_path`, `replace_entrypoints`, `mark_loaded`, and `Gem.add_to_load_path`
69
+ - RubyGems activation checks and writes `Gem.loaded_specs`
70
+
71
+ What matters under `Ruby::Box` is which of those pieces are box-local. In practice:
72
+
73
+ - `$LOAD_PATH` stays box-local; activating a bundle in an imported box does not inject its gem lib paths into the caller box
74
+ - `Gem.loaded_specs` is still shared across boxes in practice unless it is explicitly replaced inside the box
75
+
76
+ Duplicating `Gem.loaded_specs` inside an imported box was enough to keep a single bundled import from leaking back into the caller box, but it was still not enough to make two conflicting bundles coexist. Bundler also rewrites RubyGems entrypoints and spec state through code paths such as `replace_entrypoints`, `stub_rubygems`, `Gem::Specification.all = ...`, and `Gem.clear_paths`.
77
+
78
+ That shared activation state is why a second box can see a gem version activated by the first one even though its own `$LOAD_PATH` is separate. In local probes, activating `dotenv 3.2.0` in one box caused a second box targeting `dotenv ~> 2.0` to fail its Bundler activation and then crash under Ruby 4.0 + `Ruby::Box`.
79
+
80
+ See [TODO.md](TODO.md) for the upstream work that would help.
81
+
82
+ ## Why the complex example stays explicit
83
+
84
+ The complex example keeps load-path setup and bundled imports visible in `main.rb` on purpose. It is meant to demonstrate Carton directly, not hide the mechanics behind another layer.
data/LICENSE.txt ADDED
@@ -0,0 +1,19 @@
1
+ The MIT License (MIT)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,67 @@
1
+ <div align="center">
2
+ <h1>
3
+ Carton
4
+ </h1>
5
+ <img src="images/carton.png" alt="Carton logo" width="200" />
6
+ <p>
7
+ <strong>
8
+ Easily box your Ruby
9
+ </strong>
10
+ </p>
11
+ </div>
12
+
13
+ Carton is a thin wrapper around [`Ruby::Box`](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html) for safe, ergonomic modularization in Ruby 4+. It gives you imports and exports that feel like ES Modules while still reading like Ruby. Each carton can isolate constants, gems, and monkey patches behind a small public API, so large apps can keep clear boundaries.
14
+
15
+ ## Minimal usage
16
+
17
+ ```ruby
18
+ # user.rb
19
+ class User
20
+ def initialize(name) = @name = name
21
+ end
22
+
23
+ export_default User
24
+ ```
25
+
26
+ ```ruby
27
+ # main.rb
28
+ require 'carton'
29
+
30
+ User = import_relative 'user'
31
+ alice = User.new('Alice')
32
+ ```
33
+
34
+ Named exports return a namespace-like module:
35
+
36
+ ```ruby
37
+ # math.rb
38
+ def add(a, b) = a + b
39
+
40
+ export PI: 3.14159, add: method(:add)
41
+ ```
42
+
43
+ ```ruby
44
+ MathTools = import_relative 'math'
45
+ MathTools::PI
46
+ MathTools.add(2, 3)
47
+ ```
48
+
49
+ ## Docs
50
+
51
+ - [USAGE.md](USAGE.md) - detailed import/export, load path, and Bundler usage
52
+ - [DESIGN.md](DESIGN.md) - implementation overview and constraints
53
+ - [TODO.md](TODO.md) - future improvements and Bundler upstream ideas
54
+ - [examples/minimal/README.md](examples/minimal/README.md) - smallest example
55
+ - [examples/complex/README.md](examples/complex/README.md) - multi-carton example
56
+
57
+ ## Development
58
+
59
+ ```sh
60
+ bundle install # Install development deps
61
+ RUBY_BOX=1 bundle exec rake # Run all tests and examples
62
+ bundle exec rake format # Format code
63
+ ```
64
+
65
+ ## License
66
+
67
+ Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ STREE_FILES = '"**/*.{rb,rake,gemspec}" "**/Rakefile" "**/Gemfile"'
4
+ EXAMPLES = %w[minimal complex].freeze
5
+
6
+ desc 'Run all tests'
7
+ task :test do
8
+ test_files =
9
+ Dir.glob('test/**/*_test.rb').sort.map { |f| File.expand_path(f) }
10
+ sh "RUBY_BOX=1 ruby -Ilib -Itest -e 'ARGV.each { |f| require f }' #{test_files.join(' ')}",
11
+ verbose: false
12
+ end
13
+
14
+ namespace :example do
15
+ EXAMPLES.each do |name|
16
+ desc "Run the #{name} example"
17
+ task name.to_sym do
18
+ dir = File.join('examples', name)
19
+
20
+ puts
21
+ puts "== Running example/#{name} =="
22
+ puts
23
+
24
+ Bundler.with_unbundled_env do
25
+ # Install gems for any carton/local example gemfile.
26
+ Dir
27
+ .glob(File.join(dir, '**/Gemfile'))
28
+ .each do |gemfile|
29
+ pkg_dir = File.dirname(gemfile)
30
+ sh(
31
+ "cd #{pkg_dir} && " \
32
+ 'BUNDLE_GEMFILE=Gemfile bundle check >/dev/null 2>&1 || ' \
33
+ 'BUNDLE_GEMFILE=Gemfile bundle install --quiet',
34
+ verbose: false,
35
+ )
36
+ end
37
+
38
+ sh "RUBY_BOX=1 ruby #{File.join(dir, 'main.rb')}", verbose: false
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ desc 'Run all examples'
45
+ task examples: EXAMPLES.map { |n| "example:#{n}" }
46
+
47
+ desc 'Format code'
48
+ task :format do
49
+ sh "bundle exec stree write #{STREE_FILES}", verbose: false
50
+ end
51
+
52
+ task default: %i[test examples]
data/TODO.md ADDED
@@ -0,0 +1,33 @@
1
+ # TODO
2
+
3
+ ## Bundler / upstream
4
+
5
+ - Add a Bundler API that can activate a specific Gemfile explicitly instead of relying on process-global `ENV['BUNDLE_GEMFILE']` and `bundler/setup`.
6
+ - Make multiple bundle contexts safer for embedded runtimes. Today one carton-local bundle can work, but importing a second conflicting bundle in the same process still resolves against the first bundle's shared RubyGems activation state and can crash under `Ruby::Box`.
7
+ - Expose resolved bundle load paths without forcing full `Bundler.setup` and RubyGems entrypoint replacement.
8
+ - Make `Bundler.reset!`, `with_original_env`, and `with_unbundled_env` sufficient for re-entering Bundler with another Gemfile, or add a supported replacement for that workflow.
9
+ - Improve support for a parent process already running under Bundler importing a child carton that needs a different bundle.
10
+ - Make RubyGems activation state such as `Gem.loaded_specs` safely box-local or otherwise explicitly isolateable so two conflicting bundles can coexist under `Ruby::Box` without a subprocess.
11
+ - Investigate whether `Bundler.rubygems.replace_entrypoints`, `stub_rubygems`, `Gem::Specification.all`, and `Gem.clear_paths` can be made box-local. Duplicating `Gem.loaded_specs` inside the box isolated a single bundled import from the root box, but it was not enough to make a second conflicting bundle work.
12
+
13
+ ## Ruby::Box / upstream
14
+
15
+ - Expose more public APIs or clearer guarantees around box-local `$LOAD_PATH`, `$LOADED_FEATURES`, and feature resolution so libraries like Carton do not need to manually copy parent load paths and call `resolve_feature_path`.
16
+ - Investigate the crash path after conflicting bundled imports under Ruby 4.0 + `Ruby::Box`, even when the immediate conflict is already surfaced as a Bundler/RubyGems activation error.
17
+
18
+ ## Library
19
+
20
+ - Revisit `Carton::Box#gem_path?`; it still relies partly on path heuristics.
21
+ - Add targeted tests for bundled carton imports instead of covering that behavior only through the complex example.
22
+ - Remove the targeted Minitest `object_id` warning suppression once Ruby::Box or Minitest no longer emits it under Ruby 4.0.
23
+ - Revisit whether `import` should always return a `Carton::Exports` wrapper with a `default` accessor, or whether the current duck-typed return values are the better long-term fit.
24
+
25
+ ## Examples / docs
26
+
27
+ - Keep the root README intentionally short and push detail into focused docs.
28
+ - Keep documenting current Bundler constraints plainly until the runtime or Bundler story improves.
29
+
30
+ ## Tooling ideas
31
+
32
+ - Explore a Prism-based strict mode/linter that enforces `import`/`export` calls at file root and can build a static dependency graph without evaluating code.
33
+ - If that exists, add graph validation ideas such as cycle detection, missing import targets, dead imports, and unused exports.
data/USAGE.md ADDED
@@ -0,0 +1,173 @@
1
+ # Usage
2
+
3
+ ## Requirements
4
+
5
+ - Ruby 4.0+
6
+ - `RUBY_BOX=1`
7
+ - `require 'carton'` before using `import`, `import_relative`, `export_default`, or `export`
8
+
9
+ In Carton, an importable package is called a carton. This guide still says "package" sometimes in plain English, but "carton" is the project term.
10
+
11
+ ## Exporting
12
+
13
+ ### Single export
14
+
15
+ Use a single export when the file represents one main thing.
16
+
17
+ ```ruby
18
+ # user.rb
19
+ class User
20
+ def initialize(name) = @name = name
21
+
22
+ def greet = "Hello, #{@name}!"
23
+ end
24
+
25
+ export_default User
26
+ ```
27
+
28
+ Importing that file returns the exported object itself:
29
+
30
+ ```ruby
31
+ User = import_relative 'user'
32
+ User.new('Alice').greet
33
+ ```
34
+
35
+ ### Named exports
36
+
37
+ Use keyword arguments when the file exports a small namespace.
38
+
39
+ ```ruby
40
+ # math.rb
41
+ def add(a, b) = a + b
42
+
43
+ export(
44
+ PI: 3.14159,
45
+ version: '1.0.0',
46
+ add: method(:add)
47
+ )
48
+ ```
49
+
50
+ Importing named exports returns a `Carton::Exports` object, which behaves like a namespace module.
51
+
52
+ ```ruby
53
+ MathTools = import_relative 'math'
54
+
55
+ MathTools::PI
56
+ MathTools.version
57
+ MathTools.add(2, 3)
58
+ ```
59
+
60
+ `Carton::Exports` and bare `Carton::Box` imports share a small lookup API: `[]`, `fetch`, `fetch_values`, `values_at`, and `key?`.
61
+
62
+ Only one export call is allowed per imported file. Use `export_default value` for single exports or `export foo:, bar:` for named exports.
63
+
64
+ ## Importing
65
+
66
+ ### `import_relative`
67
+
68
+ `import_relative path` resolves relative to the calling file, like `require_relative`.
69
+
70
+ ```ruby
71
+ User = import_relative 'user'
72
+ ```
73
+
74
+ ### `import`
75
+
76
+ `import path` resolves using the current box's `$LOAD_PATH` or an absolute path.
77
+
78
+ ```ruby
79
+ Quest = import 'quest'
80
+ Widget = import '/absolute/path/to/widget.rb'
81
+ ```
82
+
83
+ When you want `import 'name'` to work across local cartons, add their `lib/` directories to `$LOAD_PATH` first.
84
+
85
+ ```ruby
86
+ cartons_dir = File.expand_path('cartons', __dir__)
87
+
88
+ Dir.glob(File.join(cartons_dir, '*/lib')).sort.each do |dir|
89
+ $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir)
90
+ end
91
+ ```
92
+
93
+ The complex example keeps that setup explicit in `examples/complex/main.rb`.
94
+
95
+ ## Destructuring
96
+
97
+ Named exports support pattern matching through `deconstruct_keys`.
98
+
99
+ ```ruby
100
+ import_relative('math') => { add:, version: }
101
+
102
+ add.(2, 3)
103
+ version
104
+ ```
105
+
106
+ You can rename during destructuring:
107
+
108
+ ```ruby
109
+ import_relative('math') => { add: sum }
110
+ sum.(10, 10)
111
+ ```
112
+
113
+ ## Constants
114
+
115
+ For exported constants, the simplest options are namespace access, `[]`, or `fetch`.
116
+
117
+ ```ruby
118
+ MathTools = import_relative 'math'
119
+ PI = MathTools::PI
120
+ ```
121
+
122
+ ```ruby
123
+ PI = import('math')[:PI]
124
+ ```
125
+
126
+ For multiple values:
127
+
128
+ ```ruby
129
+ PI, version = import_relative('math').fetch_values(:PI, :version)
130
+ ```
131
+
132
+ ## Bare imports
133
+
134
+ If a file does not call `export`, importing it returns the `Carton::Box` itself.
135
+
136
+ ```ruby
137
+ Toolbox = import 'some_script'
138
+
139
+ Toolbox::SomeConstant
140
+ Toolbox.fetch(:helper)
141
+ ```
142
+
143
+ ## Bundler inside cartons
144
+
145
+ The library works with or without Bundler. Plain cartons need no extra setup. For a bundled carton, the reliable pattern today is still: select the Gemfile around the `import` that loads that carton.
146
+
147
+ ```ruby
148
+ gemfile = File.expand_path('cartons/adventure/Gemfile', __dir__)
149
+
150
+ Adventure = Carton.with_bundle(gemfile) { import 'adventure' }
151
+ ```
152
+
153
+ `Carton.with_bundle` is just a small wrapper around the same `BUNDLE_GEMFILE` handoff. The underlying constraint is still Bundler's: `bundler/setup` discovers the active Gemfile from env/process state, and RubyGems activation state (`Gem.loaded_specs`) is still shared across boxes in practice, so conflicting bundles still cannot be switched reliably in one process today.
154
+
155
+ Current limits:
156
+
157
+ - one carton-local bundle can be activated cleanly from an unbundled parent
158
+ - a bundled parent process is not a reliable place to import a child carton with its own bundle
159
+ - conflicting bundles in one process still need a subprocess workaround
160
+
161
+ See [TODO.md](TODO.md) for the upstream Bundler work that would make this cleaner.
162
+
163
+ ## Examples
164
+
165
+ ```sh
166
+ RUBY_BOX=1 ruby examples/minimal/main.rb
167
+ RUBY_BOX=1 bundle exec rake example:complex
168
+ ```
169
+
170
+ See the example READMEs for details:
171
+
172
+ - [examples/minimal/README.md](examples/minimal/README.md)
173
+ - [examples/complex/README.md](examples/complex/README.md)
@@ -0,0 +1,33 @@
1
+ # Complex example
2
+
3
+ Multi-carton example showing the library directly: explicit load-path setup, a bundled single export loaded by name, a plain carton loaded by name, and a destructured import.
4
+
5
+ ## Cartons
6
+
7
+ | Carton | Purpose |
8
+ | --- | --- |
9
+ | `main` | explicit feature tour of the library |
10
+ | `adventure` | bundled carton imported by name |
11
+ | `quest` | plain carton imported by name |
12
+ | `loot` | carton that demonstrates a conflicting `dotenv` version via subprocess |
13
+
14
+ `main.rb` keeps the load-path setup and bundled import explicit on purpose so the example reads like a direct Carton feature tour. `Carton.with_bundle` shortens the `BUNDLE_GEMFILE` handoff, but it is still the same Bundler constraint under the hood.
15
+
16
+ ## Why `loot` still uses a subprocess
17
+
18
+ One carton-local bundle can be activated from an unbundled parent today. The trouble starts when another box tries to activate a conflicting version: box-local `$LOAD_PATH` is not enough because RubyGems activation state is still shared. `loot` therefore loads its `dotenv` data in a subprocess and selects its Gemfile there instead of using `Carton.with_bundle` during the import itself. That is tracked in [../../TODO.md](../../TODO.md).
19
+
20
+ ## Run
21
+
22
+ ```sh
23
+ RUBY_BOX=1 bundle exec rake example:complex
24
+ ```
25
+
26
+ Or manually:
27
+
28
+ ```sh
29
+ cd examples/complex/cartons/adventure && BUNDLE_GEMFILE=Gemfile bundle install
30
+ cd ../loot && BUNDLE_GEMFILE=Gemfile bundle install
31
+ cd ../../..
32
+ RUBY_BOX=1 ruby examples/complex/main.rb
33
+ ```
@@ -0,0 +1 @@
1
+ BANNER=single export loaded from a bundled carton
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'dotenv', '~> 3.0'
@@ -0,0 +1,17 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ dotenv (3.2.0)
5
+
6
+ PLATFORMS
7
+ arm64-darwin-25
8
+ ruby
9
+
10
+ DEPENDENCIES
11
+ dotenv (~> 3.0)
12
+
13
+ CHECKSUMS
14
+ dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
15
+
16
+ BUNDLED WITH
17
+ 4.0.3
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dotenv'
4
+
5
+ module Adventure
6
+ CONFIG = Dotenv.parse(File.expand_path('../.env', __dir__))
7
+ def self.banner = CONFIG.fetch('BANNER')
8
+
9
+ def self.dotenv_version = Gem.loaded_specs.fetch('dotenv').version.to_s
10
+ end
11
+
12
+ export_default Adventure
@@ -0,0 +1 @@
1
+ MESSAGE=loaded through a conflicting bundle in a subprocess
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'dotenv', '~> 2.0'
@@ -0,0 +1,17 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ dotenv (2.8.1)
5
+
6
+ PLATFORMS
7
+ arm64-darwin-25
8
+ ruby
9
+
10
+ DEPENDENCIES
11
+ dotenv (~> 2.0)
12
+
13
+ CHECKSUMS
14
+ dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
15
+
16
+ BUNDLED WITH
17
+ 4.0.3
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+
7
+ Plans = import 'quest'
8
+
9
+ # This carton is imported plainly by name. The subprocess below is where it
10
+ # selects its own Gemfile, because doing the conflicting dotenv activation
11
+ # directly in another box still collides through shared RubyGems activation
12
+ # state and can even crash under Ruby::Box today.
13
+ dotenv_payload =
14
+ begin
15
+ env_file = File.expand_path('../.env', __dir__)
16
+ gemfile = File.expand_path('../Gemfile', __dir__)
17
+ script = <<~'RUBY'
18
+ require 'json'
19
+ require 'bundler/setup'
20
+ require 'dotenv'
21
+
22
+ config = Dotenv.parse(ARGV.fetch(0))
23
+ version = Gem.loaded_specs.fetch('dotenv').version.to_s
24
+ puts JSON.generate(version:, config:)
25
+ RUBY
26
+
27
+ output, status =
28
+ Open3.capture2e(
29
+ { 'BUNDLE_GEMFILE' => gemfile },
30
+ RbConfig.ruby,
31
+ '-e',
32
+ script,
33
+ env_file,
34
+ )
35
+
36
+ unless status.success?
37
+ raise RuntimeError, "failed to load loot dotenv bundle: #{output}"
38
+ end
39
+
40
+ JSON.parse output
41
+ end
42
+
43
+ dotenv_version = dotenv_payload.fetch 'version'
44
+ legacy_message =
45
+ "#{dotenv_payload.fetch('config').fetch('MESSAGE')} | quest says: #{Plans.fetch :summary}"
46
+
47
+ export legacy_message:, DOTENV_VERSION: dotenv_version
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ FEATURES = ['single exports', 'named exports', 'import by name'].freeze
4
+ SUMMARY = 'plain carton loaded by name'
5
+
6
+ export FEATURES:, summary: SUMMARY
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../lib/carton'
4
+
5
+ cartons_dir = File.expand_path('cartons', __dir__)
6
+
7
+ # Local carton lib directories still need to be on the caller's load path when
8
+ # we want import 'name' to resolve inside nested boxes.
9
+ Dir
10
+ .glob(File.join(cartons_dir, '*/lib'))
11
+ .sort
12
+ .each { |dir| $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir) }
13
+
14
+ adventure_gemfile = File.expand_path('cartons/adventure/Gemfile', __dir__)
15
+
16
+ # Bundler still picks the active Gemfile from env/process state today, so the
17
+ # bundled import stays explicit even though the wrapper is small.
18
+ Adventure = Carton.with_bundle(adventure_gemfile) { import 'adventure' }
19
+
20
+ Plans = import 'quest'
21
+
22
+ # loot still imports by name, but it does not need Carton.with_bundle here
23
+ # because the conflicting dotenv activation happens entirely in the subprocess
24
+ # inside loot.rb, not during this import itself.
25
+ import('loot') => { legacy_message:, DOTENV_VERSION: loot_dotenv_version }
26
+
27
+ puts '-- Bundled single export --'
28
+ puts "Adventure.banner = #{Adventure.banner}"
29
+ puts "Adventure.dotenv_version = #{Adventure.dotenv_version}"
30
+ puts
31
+ puts '-- Named export loaded by name --'
32
+ puts "Plans::FEATURES = #{Plans::FEATURES.join(', ')}"
33
+ puts "Plans.summary = #{Plans.fetch :summary}"
34
+ puts
35
+ puts '-- Destructured named export --'
36
+ puts "loot dotenv version = #{loot_dotenv_version}"
37
+ puts "legacy_message = #{legacy_message}"
@@ -0,0 +1,16 @@
1
+ # Minimal example
2
+
3
+ Smallest working example of the library.
4
+
5
+ ## What it shows
6
+
7
+ - single export (`foo.rb`, `baz.rb`)
8
+ - named exports (`bar.rb`)
9
+ - `import_relative`
10
+ - one carton importing another carton internally
11
+
12
+ ## Run
13
+
14
+ ```sh
15
+ RUBY_BOX=1 ruby examples/minimal/main.rb
16
+ ```
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bar depends on Baz via import_relative
4
+ Baz = import_relative 'baz'
5
+
6
+ def hello
7
+ "Hello from Bar! (#{Baz.hello})"
8
+ end
9
+
10
+ export hello: method(:hello), MAGIC: 42
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baz
4
+ def self.hello = 'Hello from Baz!'
5
+ end
6
+
7
+ export_default Baz
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foo
4
+ def self.hello = 'Hello from Foo!'
5
+ end
6
+
7
+ export_default Foo
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../lib/carton'
4
+
5
+ # Single import — Foo exports a module
6
+ Foo = import_relative 'foo'
7
+
8
+ # Namespace import — Bar exports a hash with methods and constants
9
+ Bar = import_relative 'bar'
10
+
11
+ puts '-- Foo --'
12
+ puts Foo.hello
13
+
14
+ puts
15
+ puts '-- Bar (uses Baz internally) --'
16
+ puts Bar.hello
17
+ puts "Bar::MAGIC = #{Bar::MAGIC}"
data/images/carton.png ADDED
Binary file
data/lib/carton/box.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ class Box < Ruby::Box
5
+ include ExportMethods
6
+
7
+ UNSET_EXPORT = Object.new.freeze
8
+ private_constant :UNSET_EXPORT
9
+
10
+ def initialize
11
+ super
12
+ reset_export
13
+ end
14
+
15
+ private
16
+
17
+ def configure_for_import(parent_box:, entrypoint:, bundle_gemfile:)
18
+ inherit_load_path(parent_box)
19
+ reset_export
20
+ require_in_box(entrypoint)
21
+ set_bundle_gemfile(bundle_gemfile)
22
+ self
23
+ end
24
+
25
+ def require_in_box(feature)
26
+ eval("require #{feature.inspect}")
27
+ end
28
+
29
+ def resolve_feature_path_in_box(feature)
30
+ eval("$LOAD_PATH.resolve_feature_path(#{feature.inspect})")
31
+ end
32
+
33
+ def activate_bundle_if_configured
34
+ gemfile = ENV['BUNDLE_GEMFILE']
35
+ return unless gemfile && File.file?(gemfile)
36
+
37
+ require_in_box('bundler/setup')
38
+ end
39
+
40
+ def set_export(value)
41
+ if export_set?
42
+ raise RuntimeError, 'only one export is allowed per imported file'
43
+ end
44
+
45
+ @export = value
46
+ end
47
+
48
+ def export_set?
49
+ !@export.equal?(UNSET_EXPORT)
50
+ end
51
+
52
+ def export_value
53
+ @export
54
+ end
55
+
56
+ def reset_export
57
+ @export = UNSET_EXPORT
58
+ end
59
+
60
+ def set_bundle_gemfile(path)
61
+ return unless path
62
+
63
+ eval("ENV['BUNDLE_GEMFILE'] = #{path.inspect}")
64
+ end
65
+
66
+ def inherit_load_path(source_box)
67
+ return self unless source_box.respond_to?(:load_path)
68
+
69
+ source_box.load_path.each do |path|
70
+ next unless inherit_path?(path)
71
+
72
+ load_path << path unless load_path.include?(path)
73
+ end
74
+
75
+ self
76
+ end
77
+
78
+ def inherit_path?(path)
79
+ expanded = File.expand_path(path)
80
+ !gem_path?(expanded)
81
+ end
82
+
83
+ def gem_path?(path)
84
+ gem_roots = Gem.path.map { |root| File.expand_path(root) }
85
+ gem_roots.any? { |root| path == root || path.start_with?("#{root}/") } ||
86
+ path.include?('/vendor/bundle/') || path.include?('/bundler/gems/')
87
+ end
88
+
89
+ def lookup_entry(key)
90
+ name = key.to_s
91
+
92
+ if name.match?(/\A[A-Z]/)
93
+ lookup_constant_entry(name)
94
+ else
95
+ lookup_method_entry(name)
96
+ end
97
+ end
98
+
99
+ def lookup_constant_entry(name)
100
+ [true, const_get(name)]
101
+ rescue NameError
102
+ [false, nil]
103
+ end
104
+
105
+ def lookup_method_entry(name)
106
+ [true, eval(name)]
107
+ rescue NameError, NoMethodError
108
+ begin
109
+ [true, __send__(name)]
110
+ rescue NoMethodError
111
+ [false, nil]
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ module ExportMethods
5
+ def deconstruct_keys(keys)
6
+ return {} unless keys
7
+
8
+ keys.each_with_object({}) do |key, hash|
9
+ found, value = lookup_entry(key)
10
+ hash[key] = value if found
11
+ end
12
+ end
13
+
14
+ def [](key)
15
+ found, value = lookup_entry(key)
16
+ found ? value : nil
17
+ end
18
+
19
+ def key?(key)
20
+ lookup_entry(key).first
21
+ end
22
+
23
+ alias has_key? key?
24
+
25
+ def fetch(key, *default)
26
+ if default.size > 1
27
+ raise ArgumentError,
28
+ "wrong number of arguments (given #{default.size + 1}, expected 1..2)"
29
+ end
30
+
31
+ found, value = lookup_entry(key)
32
+ return value if found
33
+ return yield key if block_given?
34
+ return default.first unless default.empty?
35
+
36
+ raise KeyError, "key not found: #{key.inspect}"
37
+ end
38
+
39
+ def fetch_values(*keys)
40
+ keys.map { |key| fetch(key) }
41
+ end
42
+
43
+ def values_at(*keys)
44
+ keys.map { |key| self[key] }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ class Exports < ::Module
5
+ include ExportMethods
6
+
7
+ def initialize(values = {})
8
+ super()
9
+ @values = values
10
+ @lookup = {}
11
+ define_exports
12
+ end
13
+
14
+ private
15
+
16
+ def define_exports
17
+ @values.each do |key, value|
18
+ @lookup[key.to_s] = value
19
+
20
+ if key.to_s.match?(/\A[A-Z]/)
21
+ const_set(key, value)
22
+ else
23
+ define_singleton_method(
24
+ key,
25
+ ) { |*args, **kwargs, &block| value.respond_to?(:call) ? value.call(*args, **kwargs, &block) : value }
26
+ end
27
+ end
28
+ end
29
+
30
+ def lookup_entry(key)
31
+ name = key.to_s
32
+ @lookup.key?(name) ? [true, @lookup[name]] : [false, nil]
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ module KernelPatch
5
+ def import(path)
6
+ Runtime.import(path, base_dir: Dir.pwd)
7
+ end
8
+
9
+ def import_relative(path)
10
+ caller_dir = File.dirname(caller_locations(1, 1).first.path)
11
+ Runtime.import(path, base_dir: caller_dir)
12
+ end
13
+
14
+ def export(*args, **kwargs)
15
+ Runtime.export(args, kwargs)
16
+ end
17
+
18
+ def export_default(value)
19
+ Runtime.export_default(value)
20
+ end
21
+ end
22
+ end
23
+
24
+ # Inject only the Kernel patch module into Kernel.
25
+ ::Kernel.prepend(Carton::KernelPatch)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ module Runtime
5
+ ENTRYPOINT = File.expand_path('../carton.rb', __dir__)
6
+ private_constant :ENTRYPOINT
7
+
8
+ module_function
9
+
10
+ def import(path, base_dir:)
11
+ box = build_import_box
12
+ target = resolve_import_target(box, path, base_dir:)
13
+ box.__send__(:activate_bundle_if_configured) if target == path
14
+ box.__send__(:require_in_box, target)
15
+ extract_export(box)
16
+ end
17
+
18
+ def export(args, kwargs)
19
+ if args.any? || kwargs.empty?
20
+ raise ArgumentError,
21
+ 'export takes keyword arguments; use export_default for a single export'
22
+ end
23
+
24
+ current_import_box.__send__(:set_export, kwargs)
25
+ end
26
+
27
+ def export_default(value)
28
+ current_import_box.__send__(:set_export, value)
29
+ end
30
+
31
+ def build_import_box
32
+ parent_box = Ruby::Box.current
33
+ box = Carton::Box.new
34
+ bundle_gemfile = parent_box.eval("ENV['BUNDLE_GEMFILE']")
35
+ box.__send__(
36
+ :configure_for_import,
37
+ parent_box:,
38
+ entrypoint: ENTRYPOINT,
39
+ bundle_gemfile:,
40
+ )
41
+ box
42
+ end
43
+ private_class_method :build_import_box
44
+
45
+ def extract_export(box)
46
+ return box unless box.__send__(:export_set?)
47
+
48
+ value = box.__send__(:export_value)
49
+ value.is_a?(Hash) ? Carton::Exports.new(value) : value
50
+ end
51
+ private_class_method :extract_export
52
+
53
+ def current_import_box
54
+ box = Ruby::Box.current
55
+
56
+ unless box.respond_to?(:set_export, true)
57
+ raise RuntimeError,
58
+ 'export/export_default must be called from inside Carton.import/import_relative'
59
+ end
60
+
61
+ box
62
+ end
63
+ private_class_method :current_import_box
64
+
65
+ def resolve_import_target(box, path, base_dir:)
66
+ expanded = File.expand_path(path, base_dir)
67
+ return expanded if File.file?(expanded)
68
+
69
+ expanded_rb = "#{expanded}.rb"
70
+ return expanded_rb if File.file?(expanded_rb)
71
+
72
+ resolved = box.__send__(:resolve_feature_path_in_box, path)
73
+ return path unless resolved
74
+
75
+ type, resolved_path = resolved
76
+ return resolved_path if type == :rb && File.file?(resolved_path)
77
+
78
+ path
79
+ end
80
+ private_class_method :resolve_import_target
81
+ end
82
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carton
4
+ VERSION = '0.1.0'
5
+ end
data/lib/carton.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ raise 'Ruby 4.0+ is required for Carton' if RUBY_VERSION.to_f < 4.0
4
+
5
+ module Carton
6
+ module_function
7
+
8
+ def with_bundle(gemfile)
9
+ previous = ENV['BUNDLE_GEMFILE']
10
+ ENV['BUNDLE_GEMFILE'] = gemfile
11
+ yield
12
+ ensure
13
+ if previous
14
+ ENV['BUNDLE_GEMFILE'] = previous
15
+ else
16
+ ENV.delete 'BUNDLE_GEMFILE'
17
+ end
18
+ end
19
+ end
20
+
21
+ require_relative 'carton/export_methods'
22
+ require_relative 'carton/exports'
23
+ require_relative 'carton/box'
24
+ require_relative 'carton/runtime'
25
+ require_relative 'carton/kernel_patch'
26
+
27
+ module Carton
28
+ private_constant :ExportMethods
29
+ private_constant :Runtime
30
+ private_constant :KernelPatch
31
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: carton
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - David Cristofaro
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Carton is a thin wrapper around Ruby::Box for safe, ergonomic modularization
13
+ in Ruby 4+. It gives you imports and exports that feel like ES Modules while still
14
+ reading like Ruby. Each carton can isolate constants, gems, and monkey patches behind
15
+ a small public API, so large apps can keep clear boundaries.
16
+ email:
17
+ - david@dtcristo.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - AGENTS.md
23
+ - CHANGELOG.md
24
+ - DESIGN.md
25
+ - LICENSE.txt
26
+ - README.md
27
+ - Rakefile
28
+ - TODO.md
29
+ - USAGE.md
30
+ - examples/complex/README.md
31
+ - examples/complex/cartons/adventure/.env
32
+ - examples/complex/cartons/adventure/Gemfile
33
+ - examples/complex/cartons/adventure/Gemfile.lock
34
+ - examples/complex/cartons/adventure/lib/adventure.rb
35
+ - examples/complex/cartons/loot/.env
36
+ - examples/complex/cartons/loot/Gemfile
37
+ - examples/complex/cartons/loot/Gemfile.lock
38
+ - examples/complex/cartons/loot/lib/loot.rb
39
+ - examples/complex/cartons/quest/lib/quest.rb
40
+ - examples/complex/main.rb
41
+ - examples/minimal/README.md
42
+ - examples/minimal/bar.rb
43
+ - examples/minimal/baz.rb
44
+ - examples/minimal/foo.rb
45
+ - examples/minimal/main.rb
46
+ - images/carton.png
47
+ - lib/carton.rb
48
+ - lib/carton/box.rb
49
+ - lib/carton/export_methods.rb
50
+ - lib/carton/exports.rb
51
+ - lib/carton/kernel_patch.rb
52
+ - lib/carton/runtime.rb
53
+ - lib/carton/version.rb
54
+ homepage: https://github.com/dtcristo/carton
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ allowed_push_host: https://rubygems.org
59
+ homepage_uri: https://github.com/dtcristo/carton
60
+ source_code_uri: https://github.com/dtcristo/carton
61
+ changelog_uri: https://github.com/dtcristo/carton/blob/main/CHANGELOG.md
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 4.0.1
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 4.0.10
77
+ specification_version: 4
78
+ summary: Easily box your Ruby
79
+ test_files: []