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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -1
- data/README.md +103 -27
- data/lib/carton/activate_gems.rb +8 -0
- data/lib/carton/box.rb +162 -68
- data/lib/carton/bundler.rb +73 -0
- data/lib/carton/error.rb +24 -0
- data/lib/carton/gem_activation.rb +151 -0
- data/lib/carton/import_cache.rb +105 -0
- data/lib/carton/kernel_patch.rb +12 -8
- data/lib/carton/loaded_features.rb +10 -0
- data/lib/carton/namespace.rb +78 -0
- data/lib/carton/{export_methods.rb → namespace_methods.rb} +14 -3
- data/lib/carton/resolver.rb +149 -0
- data/lib/carton/runtime.rb +74 -59
- data/lib/carton/version.rb +1 -1
- data/lib/carton.rb +13 -22
- data/sig/carton.rbs +49 -0
- metadata +20 -34
- data/AGENTS.md +0 -47
- data/DESIGN.md +0 -84
- data/Rakefile +0 -52
- data/TODO.md +0 -33
- data/USAGE.md +0 -173
- data/examples/complex/README.md +0 -33
- data/examples/complex/cartons/adventure/.env +0 -1
- data/examples/complex/cartons/adventure/Gemfile +0 -5
- data/examples/complex/cartons/adventure/Gemfile.lock +0 -17
- data/examples/complex/cartons/adventure/lib/adventure.rb +0 -12
- data/examples/complex/cartons/loot/.env +0 -1
- data/examples/complex/cartons/loot/Gemfile +0 -5
- data/examples/complex/cartons/loot/Gemfile.lock +0 -17
- data/examples/complex/cartons/loot/lib/loot.rb +0 -47
- data/examples/complex/cartons/quest/lib/quest.rb +0 -6
- data/examples/complex/main.rb +0 -37
- data/examples/minimal/README.md +0 -16
- data/examples/minimal/bar.rb +0 -10
- data/examples/minimal/baz.rb +0 -7
- data/examples/minimal/foo.rb +0 -7
- data/examples/minimal/main.rb +0 -17
- data/images/carton.png +0 -0
- data/lib/carton/exports.rb +0 -35
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bed12e1aae3d8c17c74727a84d09d04ace52545dde9320ca3d5bdf39661c7a93
|
|
4
|
+
data.tar.gz: 57207a6c9c8704239b023dc0b861a4c35ead18f43b7a593c70674712d6dcf084
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
8
|
+
Real boundaries for Ruby
|
|
9
9
|
</strong>
|
|
10
10
|
</p>
|
|
11
11
|
</div>
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
55
|
+
```
|
|
56
|
+
billing.rb # the Entrypoint
|
|
57
|
+
billing/invoice.rb # inside the Carton
|
|
58
|
+
```
|
|
16
59
|
|
|
17
60
|
```ruby
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
77
|
+
Billing = import_relative 'billing'
|
|
78
|
+
Billing.invoice(42)
|
|
32
79
|
```
|
|
33
80
|
|
|
34
|
-
|
|
81
|
+
```sh
|
|
82
|
+
RUBY_BOX=1 ruby main.rb
|
|
83
|
+
```
|
|
35
84
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
- [
|
|
52
|
-
- [
|
|
53
|
-
- [
|
|
54
|
-
- [
|
|
55
|
-
- [
|
|
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
|
|
61
|
-
|
|
62
|
-
bundle exec rake
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
88
|
+
share_error_classes
|
|
22
89
|
self
|
|
23
90
|
end
|
|
24
91
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
100
|
+
@entrypoint = real_path(feature)
|
|
101
|
+
result = require_in_box(feature)
|
|
102
|
+
discover_public_entries
|
|
103
|
+
result
|
|
31
104
|
end
|
|
32
105
|
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
53
|
-
@
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
return unless path
|
|
159
|
+
def real_path(path)
|
|
160
|
+
return unless path && File.absolute_path?(path) && File.file?(path)
|
|
62
161
|
|
|
63
|
-
|
|
162
|
+
File.realpath(path)
|
|
64
163
|
end
|
|
65
164
|
|
|
66
|
-
def
|
|
67
|
-
|
|
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
|
-
|
|
170
|
+
@export = value
|
|
76
171
|
end
|
|
77
172
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
90
|
-
|
|
188
|
+
def reset_export
|
|
189
|
+
@export = NO_EXPORT
|
|
190
|
+
end
|
|
91
191
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|