carton 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a0f0305abe746709cf0b95c29f1f5d94d4ae4d794698205a4aa64fded37780b
4
- data.tar.gz: 262c64943c2e73831507b95a8dde3e3e8f2ea712f6844e25fa02de178e292bd3
3
+ metadata.gz: bed12e1aae3d8c17c74727a84d09d04ace52545dde9320ca3d5bdf39661c7a93
4
+ data.tar.gz: 57207a6c9c8704239b023dc0b861a4c35ead18f43b7a593c70674712d6dcf084
5
5
  SHA512:
6
- metadata.gz: b20c31a54f13d1a25296779c7bfc9b03adb4a6a2df8fb22cf6ebf7fa714f29b0d497ea8ddf7ca5a96e3e83cb76484d5687dfe7a9d9cb7563e5ab7d482d83f054
7
- data.tar.gz: c0db95029ee083e52ab39b535f03a89bd8cc9ad5b9cf1d0741c90d65fb919e64a1ba369377a62ec17615e6e42a69d5969ec550093adebdba527caf79e12e0fe4
6
+ metadata.gz: c583efac379f57cdd89a500fee73327dab3bfd1130c7d738556077a0484a67b444d721fbb7442b5325da47a5b88a22cd907697bf132b1fb5e13fdafccb34fe84
7
+ data.tar.gz: ccfd7ce8ea6a5693f391c7e2533a0313e9e717755ae10576543dbd112a8785a0439e171402c80242e1abd6841e1dae9f0c45ba41c31872aae70759f8a31ad6d6
data/CHANGELOG.md CHANGED
@@ -2,127 +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
+
5
49
  ## [0.2.0] - 2026-10-17
6
50
 
7
- Cartons are now gem-sized or domain-sized units: a file plus the directory
8
- named after it. This release also redesigns exports, shares imports across
9
- the process, adds gem and Bundler support, and requires Ruby 4.0.7.
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.
10
52
 
11
53
  ### Breaking
12
54
 
13
- - A Carton is now a file plus the directory named after it, the shape of a
14
- gem's `lib/`: `billing.rb` and everything under `billing/`, including
15
- Zeitwerk implicit-namespace directories. Importing a file inside a Carton,
16
- such as `import 'billing/invoice'`, raises `Carton::ImportError` naming the
17
- import to use. It used to load a second copy of the file in its own Box, so
18
- its classes did not match the Carton's.
19
- - Carton now requires Ruby 4.0.7 or later, enforced by the gemspec, and
20
- Bundler support needs Bundler 4.0.23 or later.
21
- - `export` can be called many times per file, and each call adds to one
22
- Export Namespace. A capitalized Symbol exports a constant (`export :Money`),
23
- other Symbols export methods (`export :add`, or `export def add ...`), and
24
- keywords export plain values. Replace `export add: method(:add)` with
25
- `export :add`. A missing name raises `NameError`, and a name exported twice
26
- raises `Carton::ExportError`.
27
- - Exported methods forward when called (`exports.add(...)`), while `[]`,
28
- `fetch`, and destructuring return the `Method` object. Keyword values are no
29
- longer invoked: `export handler: -> {}` makes `exports.handler` return the
30
- lambda.
31
- - `export_default` may be called only once per file and cannot be combined
32
- with `export`; both raise `Carton::ExportError`. Put other names inside the
33
- default value.
34
- - Importing a file with no exports (a Bare Carton) returns a `Carton::Exports`
35
- of the constants and top-level helpers it defines, instead of its
36
- `Carton::Box`. Importers can no longer `eval`, `require`, or read
37
- `load_path` inside another Carton. Helpers are callable directly
38
- (`toolbox.helper`), lookups return their `Method` object
39
- (`toolbox.fetch(:helper).call`), and constants are read when the import
40
- finishes. `Carton::Box` is now a private constant.
41
- - Imports are shared across the process. Each Carton file loads once, and
42
- every later `import` or `import_relative` of it returns the same value, so
43
- sibling Cartons share classes and Carton state no longer resets per import.
44
- A Carton that imports itself raises `Carton::ImportError`.
45
- - `import 'name'` no longer checks the current directory before `$LOAD_PATH`,
46
- so results no longer depend on where the app was started. Use an absolute
47
- path, `import_relative`, or add the directory to `$LOAD_PATH`.
48
- - `Carton.with_bundle { require 'bundler/setup' }` is replaced by
49
- `Carton.setup_bundle`. Pass groups as with `Bundler.setup`
50
- (`Carton.setup_bundle(:default, :test)`) and choose a Gemfile with
51
- `gemfile:`. Bundler errors raise instead of exiting the process.
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.
52
64
 
53
65
  ### Added
54
66
 
55
- - `import_relative` and absolute `import` accept a gem-shaped directory:
56
- `import_relative 'components/billing'` loads
57
- `components/billing/lib/billing.rb` with that `lib/` on the Carton's load
58
- path.
59
- - `Carton::Error`, the base class of every error Carton raises itself, with
60
- `Carton::ImportError` (circular imports, gem version mismatches) and
61
- `Carton::ExportError` (repeated or conflicting exports). They replace plain
62
- `RuntimeError`s and `ArgumentError`s, and are the same classes in every
63
- Box. `Carton::GemfileNotFound` is now a `Carton::Error`.
64
- - `Carton.setup_bundle(*groups, gemfile: nil)` sets up a Carton's own bundle.
65
- Without `gemfile:` it searches upward from the calling file for `gems.rb`
66
- or `Gemfile`. It clears any stale `BUNDLE_LOCKFILE` and restores all of
67
- `ENV` after setup, so a Carton's bundle variables never reach the rest of
68
- the process. `Carton::GemfileNotFound` is raised when there is no Gemfile.
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.
69
70
  - Public RBS signatures in `sig/carton.rbs`.
70
- - `examples/monorepo`: packages as path gems in one Gemfile, imported by gem
71
- name.
72
- - `examples/rails_in_main`: a Rails API app whose domain logic is a
73
- Zeitwerk-loaded path-gem Carton.
74
- - `examples/rails_in_cartons`: Rails loaded once in a framework Carton, with
75
- models and controllers spread across domain Cartons. The Main Box runs only
76
- Rack.
77
- - `examples/master_prelude`: an optional, unsupported hack that loads Active
78
- Support's core extensions into Ruby's Master Box so every Carton inherits
79
- them.
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.
80
75
 
81
76
  ### Changed
82
77
 
83
- - `export` and `export_default` only declare exports from the imported
84
- Entrypoint. Under plain `require`, or in other files a Carton requires, they
85
- validate their arguments and do nothing, so Carton-aware files and gems also
86
- load with `require`.
87
- - Importing a file from an activated gem activates that gem and its runtime
88
- dependencies in the Carton's Box at the caller's versions. When the caller
89
- ran Bundler setup, the Box is also restricted to the caller's bundle: every
90
- locked gem loads at its locked version, and gems outside the lockfile raise
91
- `LoadError`.
92
- - `import`, `import_relative`, `export`, and `export_default` are private
93
- Kernel methods, like `require`, so `"str".import(...)` raises
94
- `NoMethodError` and objects no longer `respond_to?` them.
95
- - `import 'name'` resolves the feature in the caller's Box and carries only
96
- the matching load-path entry into the imported Box, instead of copying the
97
- caller's whole `$LOAD_PATH`.
98
- - `RUBY_BOX=1 bundle exec` works, including Gemfiles that evaluate a gemspec.
99
- Carton no longer patches RubyGems or Bundler.
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.
100
83
 
101
84
  ### Fixed
102
85
 
103
- - Importing without `RUBY_BOX=1` raises `Carton::ImportError` explaining how to
104
- enable Boxes, instead of `NoMethodError` on `nil`.
105
- - A missing `import_relative` target raises `LoadError` instead of loading a
106
- same-named feature from the load path (such as the stdlib `json`), and a
107
- missing absolute `import` raises `LoadError` instead of `Errno::ENOENT`.
108
- - `export_default` keeps Hash, `nil`, and `false` values as Default Exports
109
- instead of treating a Hash as an Export Namespace.
110
- - A gem Carton imported from another Carton's Box gets its dependencies.
111
- Activation used to write to the importing Carton's `$LOAD_PATH`, so the
112
- gem's `require` of a dependency raised `LoadError`.
113
- - A gem Carton's dependencies load at the caller's versions instead of the
114
- newest installed ones. A later import whose caller has different versions
115
- activated raises instead of reusing the cached Carton.
116
- - Bare Carton lookup only finds constants and helpers the Entrypoint defines.
117
- Keys are no longer evaluated as Ruby expressions, and `key?` no longer calls
118
- helpers.
119
- - Hash-rest patterns (`=> { **rest }`) return every Named Export, or every
120
- member of a Bare Carton's Public Surface, instead of an empty hash.
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.
121
93
 
122
94
  ## [0.1.0] - 2026-04-16
123
95
 
124
96
  Initial release.
125
97
 
126
- [Unreleased]: https://github.com/dtcristo/carton/compare/v0.2.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
127
100
  [0.2.0]: https://github.com/dtcristo/carton/compare/v0.1.0...v0.2.0
128
101
  [0.1.0]: https://github.com/dtcristo/carton/releases/tag/v0.1.0
data/README.md CHANGED
@@ -5,21 +5,42 @@
5
5
  </h1>
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
- Split a Ruby app into gem-sized units that import and export like modules.
14
- Each Carton runs in its own
15
- [`Ruby::Box`](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html), so:
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.
16
14
 
17
- - two parts of one app can use **different versions of the same gem**;
18
- - **monkey patches stay** in the Carton that makes them;
19
- - only what a Carton exports is reachable, so **boundaries hold at runtime**.
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:
20
16
 
21
- A Carton is shaped like a gem: `billing.rb` plus the `billing/` directory, with
22
- no manifest. A gem, a path gem, a directory, or a single file can be one.
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.
23
44
 
24
45
  ## Quick start
25
46
 
@@ -29,8 +50,7 @@ Carton needs Ruby 4.0.7+ with Boxes enabled (`RUBY_BOX=1`). Install it:
29
50
  gem install carton # or add `gem 'carton'` to your Gemfile
30
51
  ```
31
52
 
32
- A Carton's Entrypoint loads its own files and exports one value with
33
- `export_default`:
53
+ A Carton's Entrypoint loads its own files and exports one value, usually its root module:
34
54
 
35
55
  ```
36
56
  billing.rb # the Entrypoint
@@ -45,19 +65,10 @@ module Billing
45
65
  def self.invoice(total) = Invoice.new(total)
46
66
  end
47
67
 
48
- export_default Billing
68
+ export Billing
49
69
  ```
50
70
 
51
- Or it exports several names with `export`. Capitalized Symbols name
52
- constants, other Symbols name methods, and keywords are plain values:
53
-
54
- ```ruby
55
- # math.rb
56
- export def add(a, b) = a + b
57
- export PI: 3.14159
58
- ```
59
-
60
- Import them:
71
+ Import it:
61
72
 
62
73
  ```ruby
63
74
  # main.rb
@@ -65,22 +76,13 @@ require 'carton'
65
76
 
66
77
  Billing = import_relative 'billing'
67
78
  Billing.invoice(42)
68
-
69
- MathTools = import_relative 'math'
70
- MathTools.add(2, 3) # => 5
71
- MathTools::PI # => 3.14159
72
79
  ```
73
80
 
74
81
  ```sh
75
82
  RUBY_BOX=1 ruby main.rb
76
83
  ```
77
84
 
78
- `import_relative 'billing/invoice'` raises `Carton::ImportError`: the file is
79
- inside the `billing` Carton, so only `billing` can be imported. `import 'name'`
80
- searches `$LOAD_PATH` like `require`, so gems and path gems import by name, and
81
- a gem-shaped directory (`components/billing/lib/billing.rb`) imports by its
82
- path. Each Carton loads once per process, and every later import returns the
83
- same value.
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.
84
86
 
85
87
  ## Gems and Bundler
86
88
 
@@ -92,22 +94,21 @@ Carton.setup_bundle # uses cartons/reports/Gemfile
92
94
  require 'bigdecimal'
93
95
  ```
94
96
 
95
- A gem in the app's bundle imports as a Carton and gets its dependencies at the
96
- app's locked versions. For a monorepo, make each package a path gem in one
97
- Gemfile and import it by name; see [examples/monorepo](examples/monorepo/README.md).
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).
98
102
 
99
103
  ## Status
100
104
 
101
- Carton is experimental, and so is `Ruby::Box`. It targets Ruby 4.0.7+ and,
102
- with Bundler, Bundler 4.0.23+. Box is not a security boundary: it isolates
103
- definitions and load state, not objects. Classes a Carton defines are shared
104
- with every importer, so reopening one changes it everywhere; see
105
- [Sharing classes across Cartons](docs/usage.md#sharing-classes-across-cartons).
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
106
 
107
107
  ## Docs
108
108
 
109
- - [usage.md](docs/usage.md): exports, imports, Bundler, gem Cartons, and testing
109
+ - [usage.md](docs/usage.md): exports, imports, Bundler, gem Cartons, testing, types, and checks
110
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
111
112
  - [todo.md](docs/todo.md): planned work and open questions
112
113
  - [internals/](docs/internals/): how `Ruby::Box`, RubyGems, and Bundler work, and upstream plans
113
114
  - [CONTEXT.md](CONTEXT.md): the glossary
@@ -116,13 +117,16 @@ Examples, each runnable with `RUBY_BOX=1 ruby examples/<name>/main.rb`:
116
117
 
117
118
  | Example | Shows |
118
119
  | --- | --- |
119
- | [minimal](examples/minimal/README.md) | A Carton with its own directory, a single-file Carton, and the error for importing a file inside a Carton |
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 |
120
124
  | [gems](examples/gems/README.md) | Two Cartons using different `bigdecimal` versions without Bundler |
121
125
  | [bundler](examples/bundler/README.md) | Cartons with their own Bundler setup, plus a path gem |
122
- | [monorepo](examples/monorepo/README.md) | Packages as path gems in one Gemfile, imported by gem name |
123
- | [rails_in_main](examples/rails_in_main/README.md) | Rails in the Main Box, domain logic in a Carton |
124
- | [rails_in_cartons](examples/rails_in_cartons/README.md) | Rails loaded once in a Carton, models and controllers in domain Cartons |
125
- | [master_prelude](examples/master_prelude/README.md) | An unsupported hack that shares Active Support with every Carton |
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.
126
130
 
127
131
  ## Development
128
132
 
@@ -4,5 +4,5 @@
4
4
  # resolves to the Box of the file being loaded, so activating from a file
5
5
  # required here updates this Box even during a nested Import.
6
6
  Carton.const_get(:GemActivation).activate(
7
- *Ruby::Box.current.__send__(:pending_gems),
7
+ *Carton.const_get(:Box).for(Ruby::Box.current).__send__(:pending_gems),
8
8
  )
data/lib/carton/box.rb CHANGED
@@ -1,15 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carton
4
- class Box < Ruby::Box
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
5
9
  ACTIVATE_GEMS = File.expand_path('activate_gems.rb', __dir__)
6
10
  private_constant :ACTIVATE_GEMS
11
+ LOADED_FEATURES = File.expand_path('loaded_features.rb', __dir__)
12
+ private_constant :LOADED_FEATURES
7
13
  ERRORS = %i[Error ImportError ExportError GemfileNotFound].freeze
8
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
12
- reset_export_declaration
26
+ @box = Ruby::Box.new
27
+ @box.instance_variable_set(IVAR, self)
28
+ route_autoloads_through_kernel_require
29
+ reset_export
13
30
  end
14
31
 
15
32
  private
@@ -25,20 +42,50 @@ module Carton
25
42
  gems: target.gems || [],
26
43
  locked_gems: target.locked_gems,
27
44
  )
45
+ record_used_gems(target)
28
46
  export_public_surface(&build)
29
47
  end
30
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
+
31
79
  # Fresh boxes copy Master, so they start without the caller's gems. Load
32
80
  # Carton's own entrypoint so the imported file gets `import`,
33
81
  # `import_relative`, and `export`. Nested imports reuse the caller's
34
82
  # process-wide import cache.
35
83
  def configure_for_import(entrypoint:, import_cache:)
36
84
  @import_cache = import_cache
37
- reset_export_declaration
85
+ reset_export
38
86
  add_import_load_path(File.dirname(entrypoint))
39
87
  require_in_box(entrypoint)
40
88
  share_error_classes
41
- snapshot_import_support
42
89
  self
43
90
  end
44
91
 
@@ -46,6 +93,9 @@ module Carton
46
93
  def load_import(feature, load_path: nil, gems: [], locked_gems: nil)
47
94
  activate_gems(gems, locked_gems) unless gems.empty?
48
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
49
99
 
50
100
  @entrypoint = real_path(feature)
51
101
  result = require_in_box(feature)
@@ -55,8 +105,21 @@ module Carton
55
105
 
56
106
  attr_reader :import_cache, :pending_gems
57
107
 
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}")
116
+ end
117
+ end
118
+
119
+ # `eval` rather than `Ruby::Box#require`, so the Box's own `Kernel#require`
120
+ # runs, including patches such as Zeitwerk's.
58
121
  def require_in_box(feature)
59
- eval("require #{feature.inspect}")
122
+ @box.eval("require #{feature.inspect}")
60
123
  end
61
124
 
62
125
  # Box methods run in the Box that defined them, so this Box's own copy of
@@ -75,7 +138,7 @@ module Carton
75
138
  # Point them at the importer's classes, so one rescue catches errors
76
139
  # raised in any Carton.
77
140
  def share_error_classes
78
- carton = const_get(:Carton, false)
141
+ carton = @box.const_get(:Carton, false)
79
142
  ERRORS.each do |name|
80
143
  carton.__send__(:remove_const, name)
81
144
  carton.const_set(name, Carton.const_get(name, false))
@@ -83,6 +146,7 @@ module Carton
83
146
  end
84
147
 
85
148
  def add_import_load_path(path)
149
+ load_path = @box.load_path
86
150
  load_path.unshift(path) unless load_path.include?(path)
87
151
  end
88
152
 
@@ -98,43 +162,46 @@ module Carton
98
162
  File.realpath(path)
99
163
  end
100
164
 
101
- def declare_export(methods, values)
102
- @export_declaration = @export_declaration.add(methods, values)
103
- end
165
+ def declare_export(value)
166
+ unless NO_EXPORT.equal?(@export)
167
+ raise ExportError, 'export may be called only once per Entrypoint'
168
+ end
104
169
 
105
- def declare_default(value)
106
- @export_declaration = @export_declaration.default(value)
170
+ @export = value
107
171
  end
108
172
 
109
- # A Bare Carton's surface is the constants and top-level helpers its
110
- # Entrypoint defined, so importers never receive the Box itself.
111
- def export_public_surface(&)
112
- @export_declaration.public_surface(bare: method(:bare_surface), &)
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
113
178
  end
114
179
 
115
180
  def bare_surface
116
181
  constants =
117
- @public_constant_names.to_h { |name| [name, const_get(name, false)] }
182
+ @public_constant_names.to_h do |name|
183
+ [name, @box.const_get(name, false)]
184
+ end
118
185
  [@public_method_entries, constants]
119
186
  end
120
187
 
121
- def reset_export_declaration
122
- @export_declaration = ExportDeclaration.absent
188
+ def reset_export
189
+ @export = NO_EXPORT
123
190
  end
124
191
 
125
192
  def snapshot_import_support
126
- @import_constant_names = constants(false).map(&:to_s)
193
+ @import_constant_names = @box.constants(false).map(&:to_s)
127
194
  @import_method_names = top_level_methods.keys
128
195
  end
129
196
 
130
197
  def discover_public_entries
131
198
  @public_constant_names =
132
- constants(false).map(&:to_s) - @import_constant_names
199
+ @box.constants(false).map(&:to_s) - @import_constant_names
133
200
  @public_method_entries = top_level_methods.except(*@import_method_names)
134
201
  end
135
202
 
136
203
  def top_level_methods
137
- eval(
204
+ @box.eval(
138
205
  'Object.private_instance_methods(false).to_h { |name| [name.to_s, method(name)] }',
139
206
  )
140
207
  end
data/lib/carton/error.rb CHANGED
@@ -12,8 +12,9 @@ module Carton
12
12
  class ImportError < Error
13
13
  end
14
14
 
15
- # An Export Declaration that breaks the rules: a name exported twice,
16
- # `export_default` called twice, or both forms in one Entrypoint.
15
+ # An `export` that breaks the rules: called twice in one Entrypoint, given
16
+ # a Symbol, or a Bare Carton helper that would hide a `Carton::Namespace`
17
+ # method.
17
18
  class ExportError < Error
18
19
  end
19
20