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 +4 -4
- data/CHANGELOG.md +75 -102
- data/README.md +50 -46
- data/lib/carton/activate_gems.rb +1 -1
- data/lib/carton/box.rb +89 -22
- data/lib/carton/error.rb +3 -2
- data/lib/carton/gem_activation.rb +51 -17
- data/lib/carton/import_cache.rb +61 -26
- data/lib/carton/kernel_patch.rb +5 -11
- data/lib/carton/loaded_features.rb +10 -0
- data/lib/carton/namespace.rb +78 -0
- data/lib/carton/{export_methods.rb → namespace_methods.rb} +13 -7
- data/lib/carton/resolver.rb +39 -10
- data/lib/carton/runtime.rb +36 -42
- data/lib/carton/version.rb +1 -1
- data/lib/carton.rb +3 -5
- data/sig/carton.rbs +11 -4
- metadata +9 -9
- data/lib/carton/export_declaration.rb +0 -72
- data/lib/carton/exports.rb +0 -54
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,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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
- `
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
72
|
-
- `examples/
|
|
73
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
-
|
|
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
|
-
|
|
105
|
-
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
-
|
|
109
|
-
|
|
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.
|
|
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
|
-
|
|
8
|
+
Real boundaries for Ruby
|
|
9
9
|
</strong>
|
|
10
10
|
</p>
|
|
11
11
|
</div>
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
68
|
+
export Billing
|
|
49
69
|
```
|
|
50
70
|
|
|
51
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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,
|
|
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
|
-
| [
|
|
123
|
-
| [
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
data/lib/carton/activate_gems.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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(
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
@export_declaration = @export_declaration.default(value)
|
|
170
|
+
@export = value
|
|
107
171
|
end
|
|
108
172
|
|
|
109
|
-
#
|
|
110
|
-
# Entrypoint defined, so importers never
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
122
|
-
@
|
|
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
|
|
16
|
-
#
|
|
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
|
|