carton 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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/README.md +103 -27
  4. data/lib/carton/activate_gems.rb +8 -0
  5. data/lib/carton/box.rb +162 -68
  6. data/lib/carton/bundler.rb +73 -0
  7. data/lib/carton/error.rb +24 -0
  8. data/lib/carton/gem_activation.rb +151 -0
  9. data/lib/carton/import_cache.rb +105 -0
  10. data/lib/carton/kernel_patch.rb +12 -8
  11. data/lib/carton/loaded_features.rb +10 -0
  12. data/lib/carton/namespace.rb +78 -0
  13. data/lib/carton/{export_methods.rb → namespace_methods.rb} +14 -3
  14. data/lib/carton/resolver.rb +149 -0
  15. data/lib/carton/runtime.rb +74 -59
  16. data/lib/carton/version.rb +1 -1
  17. data/lib/carton.rb +13 -22
  18. data/sig/carton.rbs +49 -0
  19. metadata +20 -34
  20. data/AGENTS.md +0 -47
  21. data/DESIGN.md +0 -84
  22. data/Rakefile +0 -52
  23. data/TODO.md +0 -33
  24. data/USAGE.md +0 -173
  25. data/examples/complex/README.md +0 -33
  26. data/examples/complex/cartons/adventure/.env +0 -1
  27. data/examples/complex/cartons/adventure/Gemfile +0 -5
  28. data/examples/complex/cartons/adventure/Gemfile.lock +0 -17
  29. data/examples/complex/cartons/adventure/lib/adventure.rb +0 -12
  30. data/examples/complex/cartons/loot/.env +0 -1
  31. data/examples/complex/cartons/loot/Gemfile +0 -5
  32. data/examples/complex/cartons/loot/Gemfile.lock +0 -17
  33. data/examples/complex/cartons/loot/lib/loot.rb +0 -47
  34. data/examples/complex/cartons/quest/lib/quest.rb +0 -6
  35. data/examples/complex/main.rb +0 -37
  36. data/examples/minimal/README.md +0 -16
  37. data/examples/minimal/bar.rb +0 -10
  38. data/examples/minimal/baz.rb +0 -7
  39. data/examples/minimal/foo.rb +0 -7
  40. data/examples/minimal/main.rb +0 -17
  41. data/images/carton.png +0 -0
  42. data/lib/carton/exports.rb +0 -35
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3d0c8755a90c3eb40d7ee29a16a174a89f1c678e43ba08275fab4502b298fae
4
- data.tar.gz: 582512d0d84c6e3dfa2b49c9d95b5e77e4fac4a25a45b63b17bf9ed6087d65c1
3
+ metadata.gz: bed12e1aae3d8c17c74727a84d09d04ace52545dde9320ca3d5bdf39661c7a93
4
+ data.tar.gz: 57207a6c9c8704239b023dc0b861a4c35ead18f43b7a593c70674712d6dcf084
5
5
  SHA512:
6
- metadata.gz: f48e9cdd2c5c64d12f323de0acc65498f7e0c0aa7f230decdf5fae538337e268c07686e9f2b5017b4d38dd1b4c22bb2120bc5c54b5ce6884f59d7b319779b0e5
7
- data.tar.gz: 1470bb044aaacf574535228f1bc9f12a84f0654651c3fbaa52fe52bcde0f215c9262eb0d603c6cc98239c29f4f44854b07534ff608af0bc206e701f2498f1cc0
6
+ metadata.gz: c583efac379f57cdd89a500fee73327dab3bfd1130c7d738556077a0484a67b444d721fbb7442b5325da47a5b88a22cd907697bf132b1fb5e13fdafccb34fe84
7
+ data.tar.gz: ccfd7ce8ea6a5693f391c7e2533a0313e9e717755ae10576543dbd112a8785a0439e171402c80242e1abd6841e1dae9f0c45ba41c31872aae70759f8a31ad6d6
data/CHANGELOG.md CHANGED
@@ -2,9 +2,100 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-10-18
6
+
7
+ `export` now takes one value, which `import` returns as is. This release also adds the `carton-tools` gem, whose `carton` command checks imports and exports and writes RBS, and makes a Carton's autoloads load in its own Box.
8
+
9
+ ### Breaking
10
+
11
+ - `export` takes one value, which `import` returns as is, and replaces `export_default`: write `export Billing`. Named Exports are removed, since a Ruby module is already a namespace. Export a module of methods instead, or use keywords, which now export a plain Hash: `export version: '1.0'` returns `{ version: '1.0' }`, so read it with `[:version]` or a `=>` pattern. `export` may be called once per Entrypoint, and a Symbol, as in `export def add` or `export :Money`, raises `Carton::ExportError`.
12
+ - `Carton::Exports` is renamed `Carton::Namespace`, and only Bare Cartons return it.
13
+
14
+ ### Added
15
+
16
+ - The `carton-tools` gem, with a `carton` command that reads code with Prism and never runs it, so it needs no `RUBY_BOX=1`. Give it the app's files; it follows imports into every Carton they reach. It is released with `carton` at exactly the same version, and `carton` itself stays the runtime only, with no dependencies and no executable.
17
+ - `carton check --strict` fails on warnings as well as errors, and a `# carton:dynamic` comment marks an intentionally dynamic import, such as plugin discovery, to silence its warning.
18
+ - `carton check` follows names bound to imports and reports, as errors, a key an exported Hash does not have, read with `fetch(:nope)` or a `=>` pattern, and a reference to an exported module's private constant (`Billing::Tax`).
19
+ - `bench/scale.rb` (`rake bench`) measures import time and memory as Cartons and bundles grow; [docs/performance.md](docs/performance.md) has the results.
20
+ - `Carton::Namespace#keys` and `#to_h`, and a readable `inspect`: `#<Carton::Namespace add, PI>`.
21
+ - `examples/plugins`, a report runner whose two plugins load bigdecimal 3.3.1 and 4.1.1 in one process from their own bundles, while one plugin's `Float#to_s` patch stays out of the host and the other plugin.
22
+ - `carton rbs` writes `sig/generated/carton.rbs` so Steep knows what each import returns: an exported constant's own type, a record type for a keyword export, or a generated `Carton::Namespace::*` module for a Bare Carton, typed from rbs-inline `#:` annotations and literal values.
23
+ - `carton check` reports missing imports, imports of files inside a Carton, import cycles, `require` calls that load a second copy of an imported Carton, and invalid `export` calls, and exits non-zero on errors.
24
+ - `carton check` treats a gem Carton's gemspec as its manifest: it reports each gem the Carton imports or requires that its gemspec does not declare.
25
+ - `examples/monorepo/test` shows both ways to test a Carton: a unit test that loads `pricing` with plain `require` and tests its private `Rounding`, and an integration test that imports the packages and sees only their exports. `rake example:<name>` runs an example's `test/**/*_test.rb` after `main.rb`.
26
+ - `examples/rails_in_cartons` declares the `logger` dependency its `shop` Carton requires.
27
+ - `sig/carton.rbs` declares `Carton::NamespaceMethods`, which `Carton::Namespace` includes.
28
+ - The minimal example type-checks with Steep.
29
+ - `examples/zeitwerk` shows a Carton whose loader uses Zeitwerk 2.8 nsfiles (`loader.nsfile = 'ns.rb'`): a lazy namespace defined in `ns.rb` loads in the Carton's Box when the app references it first, and reloads with the Carton's other files. Another Carton runs Zeitwerk 2.7.5, without nsfiles, in the same process.
30
+ - `examples/zeitwerk` shows Zeitwerk with Cartons outside Rails: a Carton using `Zeitwerk::Loader.for_gem`, a separate Zeitwerk per Box, an app loader that ignores a Carton in its directory, eager loading so autoloads never run in an importer's Box, and reloading, which leaves importers' references stale.
31
+
32
+ ### Changed
33
+
34
+ - The README and gem summary present Carton as the package system Ruby has been missing, with the tagline "Real boundaries for Ruby", and compare it with Bundler and Packwerk. The README states what a Box does and does not isolate next to its promises, and says where Carton fits today: an app's or monorepo's domains, plugin hosts with conflicting gem versions, Rails inside Cartons as experimental, and not file-by-file loading. Usage recommends exporting a root module, and the docs show shared imports' consequences early, with a plugin testing recipe. Rails in the Main Box is the recommended Rails setup.
35
+ - A Bare Carton helper named like a method every `Carton::Namespace` has, such as `def fetch` or `def name`, raises `Carton::ExportError`. It used to replace the built-in method, so `namespace.fetch(:fetch)` broke. `carton check` reports these too.
36
+ - A missing `import_relative` target's `LoadError` names the files Carton tried, and a name `import` cannot find says it searched `$LOAD_PATH`, suggesting `import_relative` when a matching file is beside the caller.
37
+ - The Rails examples eager load their Cartons' Zeitwerk loaders, so `rails_in_main` no longer loads `Billing::Invoice` in the Main Box, and both gain a Development section on restarting after changes. Each example README now opens with the problem it solves, and `master_prelude` moved out of the README's examples table to an Experiments note.
38
+
39
+ ### Fixed
40
+
41
+ - An Entrypoint that starts a thread which imports another Carton, and waits for it, no longer blocks forever. The import cache held one global lock while running every Entrypoint; it now locks only its bookkeeping and makes a thread wait only for an Entrypoint another thread is loading.
42
+ - `carton check` no longer reports `export` calls in different branches of one `if`, `unless`, or `case` as duplicates, since only one branch runs.
43
+ - A shared gem Carton raises `Carton::ImportError` when a later importer locks a different version of a gem it loaded without declaring, such as an undeclared gem the first importer's lockfile allowed, including one it required lazily after its import. Before, only its declared dependencies were compared, so it could be shared silently.
44
+ - A Bare Carton imported from a gem no longer exposes `Bundler`, `Psych`, `Date`, or `DateTime`, which Carton's gem activation loaded into its Box.
45
+ - A gem Carton's default-gem dependencies, such as `json` and `date`, keep the importer's activated version when a newer one is installed.
46
+ - The master_prelude example and Box docs no longer claim the Main Box is unaffected: patches loaded into Master can reach Main and existing Boxes, depending on Ruby's copy-on-write state.
47
+ - An autoload a Carton defines, such as a lazy Zeitwerk constant, loads in the Carton's Box even when an importer references it first. It used to run in the importer's Box, often the Main Box, or raise `NameError` when the importer used another name. Autoloaded files also go through the Box's `Kernel#require`, so Zeitwerk's `on_load` callbacks and reloading see them.
48
+
49
+ ## [0.2.0] - 2026-10-17
50
+
51
+ Cartons are now gem-sized or domain-sized units: a file plus the directory named after it. This release also redesigns exports, shares imports across the process, adds gem and Bundler support, and requires Ruby 4.0.7.
52
+
53
+ ### Breaking
54
+
55
+ - A Carton is now a file plus the directory named after it, the shape of a gem's `lib/`: `billing.rb` and everything under `billing/`, including Zeitwerk implicit-namespace directories. Importing a file inside a Carton, such as `import 'billing/invoice'`, raises `Carton::ImportError` naming the import to use. It used to load a second copy of the file in its own Box, so its classes did not match the Carton's.
56
+ - Carton now requires Ruby 4.0.7 or later, enforced by the gemspec, and Bundler support needs Bundler 4.0.23 or later.
57
+ - `export` can be called many times per file, and each call adds to one Export Namespace. A capitalized Symbol exports a constant (`export :Money`), other Symbols export methods (`export :add`, or `export def add ...`), and keywords export plain values. Replace `export add: method(:add)` with `export :add`. A missing name raises `NameError`, and a name exported twice raises `Carton::ExportError`.
58
+ - Exported methods forward when called (`exports.add(...)`), while `[]`, `fetch`, and destructuring return the `Method` object. Keyword values are no longer invoked: `export handler: -> {}` makes `exports.handler` return the lambda.
59
+ - `export_default` may be called only once per file and cannot be combined with `export`; both raise `Carton::ExportError`. Put other names inside the default value.
60
+ - Importing a file with no exports (a Bare Carton) returns a `Carton::Exports` of the constants and top-level helpers it defines, instead of its `Carton::Box`. Importers can no longer `eval`, `require`, or read `load_path` inside another Carton. Helpers are callable directly (`toolbox.helper`), lookups return their `Method` object (`toolbox.fetch(:helper).call`), and constants are read when the import finishes. `Carton::Box` is now a private constant.
61
+ - Imports are shared across the process. Each Carton file loads once, and every later `import` or `import_relative` of it returns the same value, so sibling Cartons share classes and Carton state no longer resets per import. A Carton that imports itself raises `Carton::ImportError`.
62
+ - `import 'name'` no longer checks the current directory before `$LOAD_PATH`, so results no longer depend on where the app was started. Use an absolute path, `import_relative`, or add the directory to `$LOAD_PATH`.
63
+ - `Carton.with_bundle { require 'bundler/setup' }` is replaced by `Carton.setup_bundle`. Pass groups as with `Bundler.setup` (`Carton.setup_bundle(:default, :test)`) and choose a Gemfile with `gemfile:`. Bundler errors raise instead of exiting the process.
64
+
65
+ ### Added
66
+
67
+ - `import_relative` and absolute `import` accept a gem-shaped directory: `import_relative 'components/billing'` loads `components/billing/lib/billing.rb` with that `lib/` on the Carton's load path.
68
+ - `Carton::Error`, the base class of every error Carton raises itself, with `Carton::ImportError` (circular imports, gem version mismatches) and `Carton::ExportError` (repeated or conflicting exports). They replace plain `RuntimeError`s and `ArgumentError`s, and are the same classes in every Box. `Carton::GemfileNotFound` is now a `Carton::Error`.
69
+ - `Carton.setup_bundle(*groups, gemfile: nil)` sets up a Carton's own bundle. Without `gemfile:` it searches upward from the calling file for `gems.rb` or `Gemfile`. It clears any stale `BUNDLE_LOCKFILE` and restores all of `ENV` after setup, so a Carton's bundle variables never reach the rest of the process. `Carton::GemfileNotFound` is raised when there is no Gemfile.
70
+ - Public RBS signatures in `sig/carton.rbs`.
71
+ - `examples/monorepo`: packages as path gems in one Gemfile, imported by gem name.
72
+ - `examples/rails_in_main`: a Rails API app whose domain logic is a Zeitwerk-loaded path-gem Carton.
73
+ - `examples/rails_in_cartons`: Rails loaded once in a framework Carton, with models and controllers spread across domain Cartons. The Main Box runs only Rack.
74
+ - `examples/master_prelude`: an optional, unsupported hack that loads Active Support's core extensions into Ruby's Master Box so every Carton inherits them.
75
+
76
+ ### Changed
77
+
78
+ - `export` and `export_default` only declare exports from the imported Entrypoint. Under plain `require`, or in other files a Carton requires, they validate their arguments and do nothing, so Carton-aware files and gems also load with `require`.
79
+ - Importing a file from an activated gem activates that gem and its runtime dependencies in the Carton's Box at the caller's versions. When the caller ran Bundler setup, the Box is also restricted to the caller's bundle: every locked gem loads at its locked version, and gems outside the lockfile raise `LoadError`.
80
+ - `import`, `import_relative`, `export`, and `export_default` are private Kernel methods, like `require`, so `"str".import(...)` raises `NoMethodError` and objects no longer `respond_to?` them.
81
+ - `import 'name'` resolves the feature in the caller's Box and carries only the matching load-path entry into the imported Box, instead of copying the caller's whole `$LOAD_PATH`.
82
+ - `RUBY_BOX=1 bundle exec` works, including Gemfiles that evaluate a gemspec. Carton no longer patches RubyGems or Bundler.
83
+
84
+ ### Fixed
85
+
86
+ - Importing without `RUBY_BOX=1` raises `Carton::ImportError` explaining how to enable Boxes, instead of `NoMethodError` on `nil`.
87
+ - A missing `import_relative` target raises `LoadError` instead of loading a same-named feature from the load path (such as the stdlib `json`), and a missing absolute `import` raises `LoadError` instead of `Errno::ENOENT`.
88
+ - `export_default` keeps Hash, `nil`, and `false` values as Default Exports instead of treating a Hash as an Export Namespace.
89
+ - A gem Carton imported from another Carton's Box gets its dependencies. Activation used to write to the importing Carton's `$LOAD_PATH`, so the gem's `require` of a dependency raised `LoadError`.
90
+ - A gem Carton's dependencies load at the caller's versions instead of the newest installed ones. A later import whose caller has different versions activated raises instead of reusing the cached Carton.
91
+ - Bare Carton lookup only finds constants and helpers the Entrypoint defines. Keys are no longer evaluated as Ruby expressions, and `key?` no longer calls helpers.
92
+ - Hash-rest patterns (`=> { **rest }`) return every Named Export, or every member of a Bare Carton's Public Surface, instead of an empty hash.
93
+
5
94
  ## [0.1.0] - 2026-04-16
6
95
 
7
96
  Initial release.
8
97
 
9
- [Unreleased]: https://github.com/dtcristo/carton/compare/v0.1.0...HEAD
98
+ [Unreleased]: https://github.com/dtcristo/carton/compare/v0.3.0...HEAD
99
+ [0.3.0]: https://github.com/dtcristo/carton/compare/v0.2.0...v0.3.0
100
+ [0.2.0]: https://github.com/dtcristo/carton/compare/v0.1.0...v0.2.0
10
101
  [0.1.0]: https://github.com/dtcristo/carton/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,65 +1,141 @@
1
1
  <div align="center">
2
+ <img src="images/carton.png" alt="Carton logo" width="200" />
2
3
  <h1>
3
4
  Carton
4
5
  </h1>
5
- <img src="images/carton.png" alt="Carton logo" width="200" />
6
6
  <p>
7
7
  <strong>
8
- Easily box your Ruby
8
+ Real boundaries for Ruby
9
9
  </strong>
10
10
  </p>
11
11
  </div>
12
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.
13
+ Ruby has gems to share code and Bundler to pick their versions, but nothing inside an app draws a boundary. Every file loads into one namespace: any code can reach any constant, a monkey patch applies everywhere, and one Gemfile fixes one version of each gem for the whole process. A boundary is a convention.
14
+
15
+ Carton is the package system Ruby has been missing. Split code into Cartons that import and export like modules. Each Carton runs in its own [`Ruby::Box`](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html), so Ruby enforces the boundary while the code runs:
16
+
17
+ - other Cartons reach **only what a Carton exports**;
18
+ - **monkey patches to builtin classes**, such as `String#blank?`, stay in the Carton that makes them;
19
+ - two Cartons can use **different versions of the same gem**.
20
+
21
+ A Carton is shaped like a gem: `billing.rb` plus the `billing/` directory, with no manifest. A gem, a path gem, a directory, or a single file can be one, and a Carton with a gemspec can leave the app as a published gem unchanged.
22
+
23
+ A Box does not isolate everything. Classes and objects a Carton exports are shared, not copied, so reopening an exported class or changing a shared object affects every importer. A plain `require` of a Carton's files loads a second copy outside its boundary, which `carton check` reports. And each Carton loads once per process: its state lasts as long as the process, and its first importer fixes its gem versions.
24
+
25
+ ## Why Carton
26
+
27
+ | | Bundler | [Packwerk](https://github.com/Shopify/packwerk) | Carton |
28
+ | --- | --- | --- | --- |
29
+ | Boundaries inside an app | No | Checked statically, in CI | Enforced at runtime, and checked statically by `carton check` |
30
+ | Private internals | No | Privacy checks removed in 3.0 | Only exports cross; `private_constant` hides the rest |
31
+ | Constants loaded dynamically | n/a | Not seen | Inside the boundary like any other |
32
+ | Gem versions | One per app | One per app | One per Carton |
33
+ | Monkey patches to builtins | Global | Global | Stay in their Carton |
34
+ | Unit of distribution | A gem | A pack, which stays in the app | A gem-shaped Carton, which can become a gem |
35
+
36
+ Static checks only see what they can parse. Packwerk's authors note that a package with zero violations "may actually crash with name errors when its code is executed" ([A Packwerk Retrospective](https://shopify.engineering/a-packwerk-retrospective)). A Carton's boundary is the Box it runs in, so what `carton check` reports ahead of time is also what Ruby enforces.
37
+
38
+ ## Where it fits today
39
+
40
+ - **The domains of an app or a monorepo**, as tens of Cartons. See [examples/monorepo](examples/monorepo/README.md), and [examples/rails_in_main](examples/rails_in_main/README.md) for domain Cartons beside a Rails app in the Main Box.
41
+ - **Plugin hosts whose plugins need conflicting gem versions**, which plain Ruby cannot load together; see [examples/plugins](examples/plugins/README.md).
42
+ - **Rails inside Cartons** works but is experimental: there is no reloading yet, and domain Cartons see Active Support's core extensions only if they load them ([examples/rails_in_cartons](examples/rails_in_cartons/README.md)).
43
+ - **Not yet file by file.** Each Carton costs about 60 ms and 2.4 MB ([performance.md](docs/performance.md)), most of it `Ruby::Box.new`, so aim for tens of Cartons, not hundreds.
44
+
45
+ ## Quick start
46
+
47
+ Carton needs Ruby 4.0.7+ with Boxes enabled (`RUBY_BOX=1`). Install it:
48
+
49
+ ```sh
50
+ gem install carton # or add `gem 'carton'` to your Gemfile
51
+ ```
52
+
53
+ A Carton's Entrypoint loads its own files and exports one value, usually its root module:
14
54
 
15
- ## Minimal usage
55
+ ```
56
+ billing.rb # the Entrypoint
57
+ billing/invoice.rb # inside the Carton
58
+ ```
16
59
 
17
60
  ```ruby
18
- # user.rb
19
- class User
20
- def initialize(name) = @name = name
61
+ # billing.rb
62
+ require_relative 'billing/invoice'
63
+
64
+ module Billing
65
+ def self.invoice(total) = Invoice.new(total)
21
66
  end
22
67
 
23
- export_default User
68
+ export Billing
24
69
  ```
25
70
 
71
+ Import it:
72
+
26
73
  ```ruby
27
74
  # main.rb
28
75
  require 'carton'
29
76
 
30
- User = import_relative 'user'
31
- alice = User.new('Alice')
77
+ Billing = import_relative 'billing'
78
+ Billing.invoice(42)
32
79
  ```
33
80
 
34
- Named exports return a namespace-like module:
81
+ ```sh
82
+ RUBY_BOX=1 ruby main.rb
83
+ ```
35
84
 
36
- ```ruby
37
- # math.rb
38
- def add(a, b) = a + b
85
+ `import_relative 'billing/invoice'` raises `Carton::ImportError`: the file is inside the `billing` Carton, so only `billing` can be imported. `import 'name'` searches `$LOAD_PATH` like `require`, so gems and path gems import by name, and a gem-shaped directory (`components/billing/lib/billing.rb`) imports by its path. Each Carton loads once per process, and every later import returns the same value.
39
86
 
40
- export PI: 3.14159, add: method(:add)
41
- ```
87
+ ## Gems and Bundler
88
+
89
+ A Carton can set up its own bundle, isolated from the app's:
42
90
 
43
91
  ```ruby
44
- MathTools = import_relative 'math'
45
- MathTools::PI
46
- MathTools.add(2, 3)
92
+ # cartons/reports/lib/reports.rb
93
+ Carton.setup_bundle # uses cartons/reports/Gemfile
94
+ require 'bigdecimal'
47
95
  ```
48
96
 
97
+ A gem in the app's bundle imports as a Carton and gets its dependencies at the app's locked versions. For a monorepo, make each package a path gem in one Gemfile and import it by name; see [examples/monorepo](examples/monorepo/README.md).
98
+
99
+ ## Types and checks
100
+
101
+ The `carton` command, in the [`carton-tools`](carton-tools/README.md) gem, reads code without running it; add `gem 'carton-tools', group: :development` beside `carton`. `carton rbs main.rb` writes RBS so [Steep](https://github.com/soutaro/steep) knows what each import returns, and `carton check main.rb` reports missing imports, cycles, `require` calls that reach into a Carton, and gems a gem Carton uses without declaring them. See [Types and checks](docs/usage.md#types-and-checks).
102
+
103
+ ## Status
104
+
105
+ Carton is experimental, and so is `Ruby::Box`. It targets Ruby 4.0.7+ and, with Bundler, Bundler 4.0.23+. Box is not a security boundary: it isolates definitions and load state, not objects. Classes a Carton defines are shared with every importer, so reopening one changes it everywhere; see [Sharing classes across Cartons](docs/usage.md#sharing-classes-across-cartons).
106
+
49
107
  ## Docs
50
108
 
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
109
+ - [usage.md](docs/usage.md): exports, imports, Bundler, gem Cartons, testing, types, and checks
110
+ - [design.md](docs/design.md): how an import runs
111
+ - [performance.md](docs/performance.md): import time and memory as Cartons and bundles grow
112
+ - [todo.md](docs/todo.md): planned work and open questions
113
+ - [internals/](docs/internals/): how `Ruby::Box`, RubyGems, and Bundler work, and upstream plans
114
+ - [CONTEXT.md](CONTEXT.md): the glossary
115
+
116
+ Examples, each runnable with `RUBY_BOX=1 ruby examples/<name>/main.rb`:
117
+
118
+ | Example | Shows |
119
+ | --- | --- |
120
+ | [minimal](examples/minimal/README.md) | A Carton with its own directory, a single-file Carton, the error for importing a file inside a Carton, and Steep types |
121
+ | [monorepo](examples/monorepo/README.md) | Packages as path gems in one Gemfile, imported by gem name |
122
+ | [rails_in_main](examples/rails_in_main/README.md) | Rails in the Main Box, domain logic in a Carton: the recommended way to use Carton with Rails |
123
+ | [plugins](examples/plugins/README.md) | A plugin host whose plugins need incompatible `bigdecimal` versions, each with its own bundle, and a `Float` patch that stays in one plugin |
124
+ | [gems](examples/gems/README.md) | Two Cartons using different `bigdecimal` versions without Bundler |
125
+ | [bundler](examples/bundler/README.md) | Cartons with their own Bundler setup, plus a path gem |
126
+ | [zeitwerk](examples/zeitwerk/README.md) | Zeitwerk loaders inside Cartons and in the app, eager loading, and what reloading a Carton does to importers |
127
+ | [rails_in_cartons](examples/rails_in_cartons/README.md) | Experimental: Rails loaded once in a Carton, models and controllers in domain Cartons |
128
+
129
+ **Experiments.** [master_prelude](examples/master_prelude/README.md) is an unsupported hack, not a pattern to copy. It loads Active Support once into Ruby's internal Master Box so every Carton starts with it, relying on Ruby behavior that Ruby's docs say does not happen.
56
130
 
57
131
  ## Development
58
132
 
59
133
  ```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
134
+ bundle install
135
+ bundle exec rake # tests, RuboCop, and every example
136
+ bundle exec rake test # tests only
137
+ bundle exec rake example:monorepo # one example
138
+ bundle exec rake format # format with Syntax Tree
63
139
  ```
64
140
 
65
141
  ## License
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Required inside a Carton Box by `Carton::Box#activate_gems`. `$LOAD_PATH`
4
+ # resolves to the Box of the file being loaded, so activating from a file
5
+ # required here updates this Box even during a nested Import.
6
+ Carton.const_get(:GemActivation).activate(
7
+ *Carton.const_get(:Box).for(Ruby::Box.current).__send__(:pending_gems),
8
+ )
data/lib/carton/box.rb CHANGED
@@ -1,115 +1,209 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carton
4
- class Box < Ruby::Box
5
- include ExportMethods
6
-
7
- UNSET_EXPORT = Object.new.freeze
8
- private_constant :UNSET_EXPORT
4
+ # One Import's state, attached to the plain `Ruby::Box` it runs in. It wraps
5
+ # a `Ruby::Box` rather than subclassing it, because Ruby keeps autoloads in
6
+ # the Box that registered them only for a plain `Ruby::Box`. Code in any Box
7
+ # finds the Import from `Ruby::Box.current` with `Box.for`.
8
+ class Box
9
+ ACTIVATE_GEMS = File.expand_path('activate_gems.rb', __dir__)
10
+ private_constant :ACTIVATE_GEMS
11
+ LOADED_FEATURES = File.expand_path('loaded_features.rb', __dir__)
12
+ private_constant :LOADED_FEATURES
13
+ ERRORS = %i[Error ImportError ExportError GemfileNotFound].freeze
14
+ private_constant :ERRORS
15
+ IVAR = :@carton_import
16
+ private_constant :IVAR
17
+
18
+ NO_EXPORT = Object.new.freeze
19
+ private_constant :NO_EXPORT
20
+
21
+ # The Import running in `ruby_box`, or nil for the Main Box and Boxes
22
+ # Carton did not create.
23
+ def self.for(ruby_box) = ruby_box.instance_variable_get(IVAR)
9
24
 
10
25
  def initialize
11
- super
26
+ @box = Ruby::Box.new
27
+ @box.instance_variable_set(IVAR, self)
28
+ route_autoloads_through_kernel_require
12
29
  reset_export
13
30
  end
14
31
 
15
32
  private
16
33
 
17
- def configure_for_import(parent_box:, entrypoint:, bundle_gemfile:)
18
- inherit_load_path(parent_box)
34
+ # The whole Import lifecycle, in order: boot Carton in this Box, load the
35
+ # target, then build its public surface with `build`. Runtime calls only
36
+ # this, so the ordering lives here.
37
+ def import_target(target, carton_entrypoint:, import_cache:, &build)
38
+ configure_for_import(entrypoint: carton_entrypoint, import_cache:)
39
+ load_import(
40
+ target.feature,
41
+ load_path: target.load_path,
42
+ gems: target.gems || [],
43
+ locked_gems: target.locked_gems,
44
+ )
45
+ record_used_gems(target)
46
+ export_public_surface(&build)
47
+ end
48
+
49
+ # The gems this Carton depends on: its closure, plus the locked gems whose
50
+ # files it has loaded, such as an undeclared gem the caller's lockfile
51
+ # allows. A later importer must agree on these versions to share the
52
+ # Carton, so they are read again at each check, after any lazy requires.
53
+ def record_used_gems(target)
54
+ target.gem_usage = -> { used_gems(target) }
55
+ end
56
+
57
+ def used_gems(target)
58
+ gems = target.gems || []
59
+ locked = (target.locked_gems || []) - gems
60
+ return gems if locked.empty?
61
+
62
+ gems + GemActivation.owning(locked, loaded_features)
63
+ end
64
+
65
+ # `Ruby::Box#eval` does not switch the loading Box, so during a nested
66
+ # Import `$LOADED_FEATURES` there would be the importer's. A file loaded
67
+ # in this Box reads this Box's.
68
+ def loaded_features
69
+ @box.eval("load #{LOADED_FEATURES.inspect}")
70
+ features = @loaded_features
71
+ @loaded_features = nil
72
+ features
73
+ end
74
+
75
+ def receive_loaded_features(features)
76
+ @loaded_features = features
77
+ end
78
+
79
+ # Fresh boxes copy Master, so they start without the caller's gems. Load
80
+ # Carton's own entrypoint so the imported file gets `import`,
81
+ # `import_relative`, and `export`. Nested imports reuse the caller's
82
+ # process-wide import cache.
83
+ def configure_for_import(entrypoint:, import_cache:)
84
+ @import_cache = import_cache
19
85
  reset_export
86
+ add_import_load_path(File.dirname(entrypoint))
20
87
  require_in_box(entrypoint)
21
- set_bundle_gemfile(bundle_gemfile)
88
+ share_error_classes
22
89
  self
23
90
  end
24
91
 
25
- def require_in_box(feature)
26
- eval("require #{feature.inspect}")
27
- end
92
+ # Activate the caller's gems for the target, then load it inside the box.
93
+ def load_import(feature, load_path: nil, gems: [], locked_gems: nil)
94
+ activate_gems(gems, locked_gems) unless gems.empty?
95
+ add_import_load_path(load_path) if load_path
96
+ # After activation, which loads Bundler and Psych here: a Bare
97
+ # Carton's surface is only what its Entrypoint adds.
98
+ snapshot_import_support
28
99
 
29
- def resolve_feature_path_in_box(feature)
30
- eval("$LOAD_PATH.resolve_feature_path(#{feature.inspect})")
100
+ @entrypoint = real_path(feature)
101
+ result = require_in_box(feature)
102
+ discover_public_entries
103
+ result
31
104
  end
32
105
 
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
106
+ attr_reader :import_cache, :pending_gems
39
107
 
40
- def set_export(value)
41
- if export_set?
42
- raise RuntimeError, 'only one export is allowed per imported file'
108
+ # Ruby 4.0.7 loads an autoload registered in this Box with
109
+ # `Ruby::Box#require`, which skips the Box's `Kernel#require` and so any
110
+ # patch on it, such as Zeitwerk's (Bug #21830, fixed on Ruby master). Send
111
+ # it through `Kernel#require` in this Box instead.
112
+ def route_autoloads_through_kernel_require
113
+ box = @box
114
+ box.define_singleton_method(:require) do |path|
115
+ box.eval("require #{path.to_s.inspect}")
43
116
  end
44
-
45
- @export = value
46
117
  end
47
118
 
48
- def export_set?
49
- !@export.equal?(UNSET_EXPORT)
119
+ # `eval` rather than `Ruby::Box#require`, so the Box's own `Kernel#require`
120
+ # runs, including patches such as Zeitwerk's.
121
+ def require_in_box(feature)
122
+ @box.eval("require #{feature.inspect}")
123
+ end
124
+
125
+ # Box methods run in the Box that defined them, so this Box's own copy of
126
+ # Carton activates gems in this Box's RubyGems. `$LOAD_PATH` resolves to
127
+ # the Box of the file being loaded, which during a nested Import is the
128
+ # importing Carton, so activation runs from a file required here.
129
+ def activate_gems(gems, locked_gems)
130
+ @pending_gems = [gems, locked_gems]
131
+ require_in_box(ACTIVATE_GEMS)
132
+ ensure
133
+ @pending_gems = nil
134
+ end
135
+
136
+ # This Box just loaded its own copy of Carton, whose error classes are new
137
+ # objects that `rescue Carton::Error` in another Box would never match.
138
+ # Point them at the importer's classes, so one rescue catches errors
139
+ # raised in any Carton.
140
+ def share_error_classes
141
+ carton = @box.const_get(:Carton, false)
142
+ ERRORS.each do |name|
143
+ carton.__send__(:remove_const, name)
144
+ carton.const_set(name, Carton.const_get(name, false))
145
+ end
50
146
  end
51
147
 
52
- def export_value
53
- @export
148
+ def add_import_load_path(path)
149
+ load_path = @box.load_path
150
+ load_path.unshift(path) unless load_path.include?(path)
54
151
  end
55
152
 
56
- def reset_export
57
- @export = UNSET_EXPORT
153
+ # Only the imported Entrypoint declares this Carton's exports. Other files
154
+ # it requires may call `export` for their own imports; here it is inert.
155
+ def entrypoint?(file)
156
+ !@entrypoint.nil? && real_path(file) == @entrypoint
58
157
  end
59
158
 
60
- def set_bundle_gemfile(path)
61
- return unless path
159
+ def real_path(path)
160
+ return unless path && File.absolute_path?(path) && File.file?(path)
62
161
 
63
- eval("ENV['BUNDLE_GEMFILE'] = #{path.inspect}")
162
+ File.realpath(path)
64
163
  end
65
164
 
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)
165
+ def declare_export(value)
166
+ unless NO_EXPORT.equal?(@export)
167
+ raise ExportError, 'export may be called only once per Entrypoint'
73
168
  end
74
169
 
75
- self
170
+ @export = value
76
171
  end
77
172
 
78
- def inherit_path?(path)
79
- expanded = File.expand_path(path)
80
- !gem_path?(expanded)
173
+ # The exported value, or for a Bare Carton the constants and top-level
174
+ # helpers its Entrypoint defined, which `build` wraps, so importers never
175
+ # receive the Box itself.
176
+ def export_public_surface
177
+ NO_EXPORT.equal?(@export) ? yield(*bare_surface) : @export
81
178
  end
82
179
 
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/')
180
+ def bare_surface
181
+ constants =
182
+ @public_constant_names.to_h do |name|
183
+ [name, @box.const_get(name, false)]
184
+ end
185
+ [@public_method_entries, constants]
87
186
  end
88
187
 
89
- def lookup_entry(key)
90
- name = key.to_s
188
+ def reset_export
189
+ @export = NO_EXPORT
190
+ end
91
191
 
92
- if name.match?(/\A[A-Z]/)
93
- lookup_constant_entry(name)
94
- else
95
- lookup_method_entry(name)
96
- end
192
+ def snapshot_import_support
193
+ @import_constant_names = @box.constants(false).map(&:to_s)
194
+ @import_method_names = top_level_methods.keys
97
195
  end
98
196
 
99
- def lookup_constant_entry(name)
100
- [true, const_get(name)]
101
- rescue NameError
102
- [false, nil]
197
+ def discover_public_entries
198
+ @public_constant_names =
199
+ @box.constants(false).map(&:to_s) - @import_constant_names
200
+ @public_method_entries = top_level_methods.except(*@import_method_names)
103
201
  end
104
202
 
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
203
+ def top_level_methods
204
+ @box.eval(
205
+ 'Object.private_instance_methods(false).to_h { |name| [name.to_s, method(name)] }',
206
+ )
113
207
  end
114
208
  end
115
209
  end