carton 0.3.0 → 0.4.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: bed12e1aae3d8c17c74727a84d09d04ace52545dde9320ca3d5bdf39661c7a93
4
- data.tar.gz: 57207a6c9c8704239b023dc0b861a4c35ead18f43b7a593c70674712d6dcf084
3
+ metadata.gz: 3e0007f7095e824c2b3bc501b140288cde630695d7c56ae96c1a2f4a968c5196
4
+ data.tar.gz: 301c877d0c63eda3b26b18f6076097b0d8d746d7a1a0228adf7a5d13d173e9c5
5
5
  SHA512:
6
- metadata.gz: c583efac379f57cdd89a500fee73327dab3bfd1130c7d738556077a0484a67b444d721fbb7442b5325da47a5b88a22cd907697bf132b1fb5e13fdafccb34fe84
7
- data.tar.gz: ccfd7ce8ea6a5693f391c7e2533a0313e9e717755ae10576543dbd112a8785a0439e171402c80242e1abd6841e1dae9f0c45ba41c31872aae70759f8a31ad6d6
6
+ metadata.gz: d3fe870cdb0bcc2ee87f9ca951abfff7c807b9847330605d25762b8be1a012952ee1c55769eefef694d2b1c52e156c4a74ef1b1640431b304ce541e5ae720b45
7
+ data.tar.gz: 693fcc780bf07ccbd8c85dd7e20919bb7d3cf8ab7fefbbf0f91aa990a835e0e34a3842cf8ef23c80fd9b6ad0364536be1333f5d06ae56b8d1c11b1796cef1c4b
data/CHANGELOG.md CHANGED
@@ -2,100 +2,147 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0] - 2026-10-22
6
+
7
+ Every imported Entrypoint now requires an explicit Export; Bare Cartons and `Carton::Namespace` are removed. `carton check` adds unused import and shared-object reopening warnings plus JSON reports. The Rails and Hanami examples share a Customers and Billing workflow, and Bundler support starts at 4.0.20 (#71, #79, #82, #83, #93, #100, #107).
8
+
9
+ ### Breaking
10
+
11
+ - Every imported Entrypoint must call `export` exactly once; an import with no Export raises `Carton::ExportError` after loading. Removed Bare Cartons and `Carton::Namespace`; adapt legacy files and gems with a distinct Entrypoint that requires them and explicitly exports the chosen value. Imports still return that value exactly, including `nil` and `false`, and plain `require` keeps working (#107).
12
+
13
+ ### Added
14
+
15
+ - `examples/hanami_in_cartons` implements the Rails examples' Customers/Billing workflow with Hanami slices, Dry contracts, types and operations, ROM SQLite repos, HTML/JSON responses, and a CLI bridge. Tests cover lazy/eager loading, Box ownership, persistence across processes, and Puma HTTP (#93).
16
+ - `carton check` warns about unused import bindings and class/module definitions that open imported shared objects, and `--format json` reports problems and counts for CI and editors. This recovers the applicable parts of the stranded static-checks work; its unused Named Export check is obsolete under the single-value Export model (#54, #82).
17
+ - An experimental Rails-in-Cartons reloading spike rebuilds changed domain Cartons and their Importers in a scratch copy, explicitly refreshes a retained Rails host's domain references and registered classes, and verifies requests after two reloads without losing records. It adds no runtime reload interface (#67).
18
+ - `examples/exports` demonstrates Hash and callable exports, an explicit adapter for legacy Ruby, and Ruby pattern matching without gems or Bundler. The plugin testing recipe is now a runnable test with its own bundle (#64, #107).
19
+ - `examples/dependency_injection`: a checkout Carton receives order storage, payments, and logging from its host through constructor arguments. File-backed demo adapters stay in Main, and integration tests import the Carton with fakes (#63).
20
+
21
+ ### Changed
22
+
23
+ - The Hanami example's Framework, Customers, Billing, and Shop are gem-shaped components under `components/`, matching the Rails example, and import each other with `import 'name'` instead of `import_relative`. Both example guides show the framework and customers Entrypoints above billing and shop (#103).
24
+ - Renamed the Rails and Hanami example directories to `examples/rails` and `examples/hanami`, with matching Rake tasks and updated fixture paths. Their existing docs URLs still work (#100).
25
+
26
+ - Aligned the homepage, share previews, README, docs, and gem descriptions around explicit imports and exports, separate dependencies, and runtime boundaries. The homepage introduction is centred and its page title no longer repeats the project name (#98).
27
+ - The website has three sections, Start here, Guides, and Reference, and the sidebar shows only the current one. Each example's guide moved from its README to a website page that shows its code from the repository. The reference is now API docs in YARD's shape, tested against `sig/carton.rbs`, with a page per `carton` command tested against its usage (#90).
28
+ - Renamed the Rails-in-Main example to `rails_with_carton_domain` and aligned both Rails examples around Customers and Billing: domain-owned registration, validation, exact invoice pricing, and persistence through injected repositories. The conventional app keeps Rails adapters in Main; the Engine app owns them in its domain Cartons. Removed the unrelated jobs, updated the private reload fixture, and placed both examples together in the docs (#83).
29
+
30
+ - Expanded Rails-in-Main into a conventional Rails app with migrations, HTML/JSON responses, an Active Job, standard CLI commands, and isolated Billing integration tests (#80, #81).
31
+
32
+ - The Rails-in-Cartons example uses isolated Rails Engines with domain-owned loaders, migrations, HTML views, GlobalID jobs, and an explicit Rails CLI bridge (#79).
33
+
34
+ - The website is now the only user guide and reference: `docs/usage.md` is gone, its remaining details moved to the website, which adds Examples and Rails pages, and the website's Status page is the only place that tracks readiness (#77).
35
+ - Moved the unsupported Master prelude and private Rails reloading experiments into `spikes/`, with dedicated tasks and CI coverage; both Rails examples remain in `examples/`. The reloading spike reuses the Rails-in-Cartons app as a read-only fixture (#76).
36
+
37
+ - Bundler support now starts at 4.0.20, the first release with the gemspec evaluation fix Carton needs inside Boxes. CI and the example bundles use that minimum (#71).
38
+ - `examples/monorepo` keeps its Cartons under `cartons/` instead of `packages/` (#65).
39
+ - `CONTEXT.md` groups the glossary and adds Interior, Gem-shaped Directory, Root Box, User Box, Carton Box, Template Box, Importer, First Importer, Second Copy, Dynamic Import, Helper, Gem Closure, Carton Bundle, and Undeclared Gem. The docs, comments, and `carton-tools` internals use these terms (#65).
40
+ - `docs/internals/root-box-vs-main-box.md` is now `docs/internals/box-names.md`, which records upstream Ruby's Box names and how they were chosen (#65).
41
+ - The examples guide explains each example's purpose and setup. The minimal app now contains only working Imports, with intentional violations in a separate file for static checks. The guides and comments follow `CONTEXT.md`, including Interior, Public Surface, Importer, Carton Bundle, and Helper. The Rails guides share restart guidance, and Bundler and Zeitwerk examples use clearer labels and names (#64).
42
+
43
+ ### Removed
44
+
45
+ - Removed `examples/rails_with_carton_domain` and its guide, leaving one Rails example with domain-owned Engines (#100).
46
+
47
+ ### Fixed
48
+
49
+ - Sample path gems declare and require Carton instead of relying on the host to preload it; the monorepo's leaf gems also work with ordinary `require` without Boxes enabled. The Rails shop gem includes its database configuration. Example docs distinguish bundle installation from activation and correct the undeclared-import demonstration and Rails class-sharing explanation (#64).
50
+
5
51
  ## [0.3.0] - 2026-10-18
6
52
 
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.
53
+ `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 (#41, #48, #58, #59).
8
54
 
9
55
  ### Breaking
10
56
 
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.
57
+ - `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` (#59).
58
+ - `Carton::Exports` is renamed `Carton::Namespace`, and only Bare Cartons return it (#59).
13
59
 
14
60
  ### Added
15
61
 
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.
62
+ - 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 (#41, #58).
63
+ - `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 (#53).
64
+ - `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`) (#53, #59).
65
+ - `bench/scale.rb` (`rake bench`) measures import time and memory as Cartons and bundles grow; [docs/performance.md](docs/performance.md) has the results (#50).
66
+ - `Carton::Namespace#keys` and `#to_h`, and a readable `inspect`: `#<Carton::Namespace add, PI>` (#43, #59).
67
+ - `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 (#45).
68
+ - `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 (#41, #59).
69
+ - `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 (#41, #59).
70
+ - `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 (#42).
71
+ - `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` (#44).
72
+ - `examples/rails_in_cartons` declares the `logger` dependency its `shop` Carton requires (#42).
73
+ - `sig/carton.rbs` declares `Carton::NamespaceMethods`, which `Carton::Namespace` includes (#41, #59).
74
+ - The minimal example type-checks with Steep (#41).
75
+ - `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 (#60).
76
+ - `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 (#46).
31
77
 
32
78
  ### Changed
33
79
 
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.
80
+ - 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 (#56, #57, #59).
81
+ - 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 (#43).
82
+ - 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 (#43).
83
+ - 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 (#47).
38
84
 
39
85
  ### Fixed
40
86
 
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.
87
+ - 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 (#55).
88
+ - `carton check` no longer reports `export` calls in different branches of one `if`, `unless`, or `case` as duplicates, since only one branch runs (#53).
89
+ - 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 (#52).
90
+ - A Bare Carton imported from a gem no longer exposes `Bundler`, `Psych`, `Date`, or `DateTime`, which Carton's gem activation loaded into its Box (#52).
91
+ - A gem Carton's default-gem dependencies, such as `json` and `date`, keep the importer's activated version when a newer one is installed (#52).
92
+ - 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 (#52).
93
+ - 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).
48
94
 
49
95
  ## [0.2.0] - 2026-10-17
50
96
 
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.
97
+ 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 (#9, #10, #11, #14, #15, #17, #18, #19, #20, #36).
52
98
 
53
99
  ### Breaking
54
100
 
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.
101
+ - 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 (#36).
102
+ - Carton now requires Ruby 4.0.7 or later, enforced by the gemspec, and Bundler support needs Bundler 4.0.23 or later (#11).
103
+ - `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` (#9, #19).
104
+ - 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 (#9).
105
+ - `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 (#20).
106
+ - 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 (#17).
107
+ - 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` (#10).
108
+ - `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` (#13).
109
+ - `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 (#11).
64
110
 
65
111
  ### Added
66
112
 
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.
113
+ - `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 (#36).
114
+ - `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` (#35).
115
+ - `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 (#11, #12).
116
+ - Public RBS signatures in `sig/carton.rbs` ([0b7d16c](https://github.com/dtcristo/carton/commit/0b7d16c6e251931759edbc65f518b11377635f5d)).
117
+ - `examples/monorepo`: packages as path gems in one Gemfile, imported by gem name (#33).
118
+ - `examples/rails_in_main`: a Rails API app whose domain logic is a Zeitwerk-loaded path-gem Carton (#21, #23).
119
+ - `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 (#22, #23).
120
+ - `examples/master_prelude`: an optional, unsupported hack that loads Active Support's core extensions into Ruby's Master Box so every Carton inherits them (#24).
75
121
 
76
122
  ### Changed
77
123
 
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.
124
+ - `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` (#18).
125
+ - 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` (#14, #15).
126
+ - `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 (#8).
127
+ - `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` ([110dfb6](https://github.com/dtcristo/carton/commit/110dfb68c7488e2e32de3e71c01fcecccbc893f8)).
128
+ - `RUBY_BOX=1 bundle exec` works, including Gemfiles that evaluate a gemspec. Carton no longer patches RubyGems or Bundler (#11).
83
129
 
84
130
  ### Fixed
85
131
 
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.
132
+ - Importing without `RUBY_BOX=1` raises `Carton::ImportError` explaining how to enable Boxes, instead of `NoMethodError` on `nil` (#35).
133
+ - 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` (#30).
134
+ - `export_default` keeps Hash, `nil`, and `false` values as Default Exports instead of treating a Hash as an Export Namespace (#4).
135
+ - 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` (#15).
136
+ - 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 (#14).
137
+ - 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 (#5).
138
+ - Hash-rest patterns (`=> { **rest }`) return every Named Export, or every member of a Bare Carton's Public Surface, instead of an empty hash (#6).
93
139
 
94
140
  ## [0.1.0] - 2026-04-16
95
141
 
96
- Initial release.
142
+ Initial release ([9925397](https://github.com/dtcristo/carton/commit/99253978dd73b70698341d04314e3ccf86b37c16)).
97
143
 
98
- [Unreleased]: https://github.com/dtcristo/carton/compare/v0.3.0...HEAD
144
+ [Unreleased]: https://github.com/dtcristo/carton/compare/v0.4.0...HEAD
145
+ [0.4.0]: https://github.com/dtcristo/carton/compare/v0.3.0...v0.4.0
99
146
  [0.3.0]: https://github.com/dtcristo/carton/compare/v0.2.0...v0.3.0
100
147
  [0.2.0]: https://github.com/dtcristo/carton/compare/v0.1.0...v0.2.0
101
148
  [0.1.0]: https://github.com/dtcristo/carton/releases/tag/v0.1.0
data/README.md CHANGED
@@ -10,132 +10,77 @@
10
10
  </p>
11
11
  </div>
12
12
 
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:
13
+ Split your app into Cartons with explicit imports and exports. Each has its own dependencies and runs in a separate [`Ruby::Box`](https://docs.ruby-lang.org/en/4.0/Ruby/Box.html), so Ruby enforces the boundaries at runtime:
16
14
 
17
15
  - 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;
16
+ - **monkey patches to builtin classes** stay in the Carton that makes them;
19
17
  - two Cartons can use **different versions of the same gem**.
20
18
 
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
19
+ Every imported Entrypoint must call `export` exactly once. Loading without an Export raises `Carton::ExportError`; importing returns the exported value itself.
39
20
 
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.
21
+ A Carton is shaped like a gem: `billing.rb` plus the `billing/` directory beside it. There is no manifest, and with a gemspec a Carton can leave the app as a gem.
44
22
 
45
23
  ## Quick start
46
24
 
47
- Carton needs Ruby 4.0.7+ with Boxes enabled (`RUBY_BOX=1`). Install it:
25
+ Carton needs Ruby 4.0.7+ with Boxes enabled (`RUBY_BOX=1`).
48
26
 
49
27
  ```sh
50
28
  gem install carton # or add `gem 'carton'` to your Gemfile
51
29
  ```
52
30
 
53
- A Carton's Entrypoint loads its own files and exports one value, usually its root module:
31
+ ```ruby
32
+ # tax.rb: the tax Carton
33
+ module Tax
34
+ def self.on(total) = total / 10
35
+ end
54
36
 
55
- ```
56
- billing.rb # the Entrypoint
57
- billing/invoice.rb # inside the Carton
37
+ export Tax # the one value importers get
58
38
  ```
59
39
 
60
40
  ```ruby
61
- # billing.rb
62
- require_relative 'billing/invoice'
41
+ # billing.rb: the billing Carton
42
+ Tax = import_relative 'tax' # billing's import, invisible to importers
63
43
 
64
44
  module Billing
65
- def self.invoice(total) = Invoice.new(total)
45
+ def self.invoice(total) = total + Tax.on(total)
66
46
  end
67
47
 
68
48
  export Billing
69
49
  ```
70
50
 
71
- Import it:
72
-
73
51
  ```ruby
74
52
  # main.rb
75
53
  require 'carton'
76
54
 
77
55
  Billing = import_relative 'billing'
78
- Billing.invoice(42)
56
+ Billing.invoice(100) # => 110
57
+ Tax # NameError: billing's import
79
58
  ```
80
59
 
81
60
  ```sh
82
61
  RUBY_BOX=1 ruby main.rb
83
62
  ```
84
63
 
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.
86
-
87
- ## Gems and Bundler
88
-
89
- A Carton can set up its own bundle, isolated from the app's:
90
-
91
- ```ruby
92
- # cartons/reports/lib/reports.rb
93
- Carton.setup_bundle # uses cartons/reports/Gemfile
94
- require 'bigdecimal'
95
- ```
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
64
  ## Status
104
65
 
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).
66
+ Carton is experimental, and so is `Ruby::Box`. Box isolates definitions and load state, not objects, so it is not a security boundary, and classes a Carton exports are shared with every importer. [Why Carton](https://carton.dtcristo.com/start/why-carton/) compares it with Bundler and Packwerk and covers where it fits today.
106
67
 
107
68
  ## Docs
108
69
 
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
70
+ Guides and reference live at [carton.dtcristo.com](https://carton.dtcristo.com) (source in [website/](website/README.md)). For contributors:
115
71
 
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 |
72
+ - [design.md](docs/design.md), [performance.md](docs/performance.md), [todo.md](docs/todo.md), and [internals/](docs/internals/)
73
+ - [CONTEXT.md](CONTEXT.md): the glossary
74
+ - [Examples](https://carton.dtcristo.com/guides/examples/): runnable apps, starting with [minimal](https://carton.dtcristo.com/guides/examples/minimal/); run one with `bundle exec rake example:<name>`
128
75
 
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.
76
+ The `carton` command, for RBS and boundary checks, is in the [`carton-tools`](carton-tools/README.md) gem.
130
77
 
131
78
  ## Development
132
79
 
133
80
  ```sh
134
81
  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
82
+ bundle exec rake # tests, RuboCop, and every example
83
+ bundle exec rake format # format with Syntax Tree
139
84
  ```
140
85
 
141
86
  ## License
data/lib/carton/box.rb CHANGED
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carton
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`.
4
+ # A Carton Box: one Import's state, attached to the plain `Ruby::Box` it
5
+ # runs in. It wraps a `Ruby::Box` rather than subclassing it, because Ruby
6
+ # keeps autoloads in the Box that registered them only for a plain
7
+ # `Ruby::Box`. Code in any Box finds the Import from `Ruby::Box.current`
8
+ # with `Box.for`.
8
9
  class Box
9
10
  ACTIVATE_GEMS = File.expand_path('activate_gems.rb', __dir__)
10
11
  private_constant :ACTIVATE_GEMS
@@ -32,10 +33,10 @@ module Carton
32
33
  private
33
34
 
34
35
  # 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:)
36
+ # target, then return its Export. Runtime calls only this, so the ordering
37
+ # lives here.
38
+ def import_target(target, library:, import_cache:)
39
+ configure_for_import(library:, import_cache:)
39
40
  load_import(
40
41
  target.feature,
41
42
  load_path: target.load_path,
@@ -43,13 +44,14 @@ module Carton
43
44
  locked_gems: target.locked_gems,
44
45
  )
45
46
  record_used_gems(target)
46
- export_public_surface(&build)
47
+ exported_value
47
48
  end
48
49
 
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.
50
+ # The gems this Carton depends on: its Gem Closure, plus the locked gems
51
+ # whose files it has loaded, such as an Undeclared Gem the Importer's
52
+ # lockfile allows. A later Importer must agree on these versions to share
53
+ # the Carton, so they are read again at each check, after any lazy
54
+ # requires.
53
55
  def record_used_gems(target)
54
56
  target.gem_usage = -> { used_gems(target) }
55
57
  end
@@ -63,7 +65,7 @@ module Carton
63
65
  end
64
66
 
65
67
  # `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
68
+ # Import `$LOADED_FEATURES` there would be the Importer's. A file loaded
67
69
  # in this Box reads this Box's.
68
70
  def loaded_features
69
71
  @box.eval("load #{LOADED_FEATURES.inspect}")
@@ -76,31 +78,25 @@ module Carton
76
78
  @loaded_features = features
77
79
  end
78
80
 
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
81
+ # New Boxes copy Master, so they start without the Importer's gems. Load
82
+ # Carton's own library so the imported file gets `import`,
83
+ # `import_relative`, and `export`. Nested imports reuse the Importer's
82
84
  # process-wide import cache.
83
- def configure_for_import(entrypoint:, import_cache:)
85
+ def configure_for_import(library:, import_cache:)
84
86
  @import_cache = import_cache
85
87
  reset_export
86
- add_import_load_path(File.dirname(entrypoint))
87
- require_in_box(entrypoint)
88
+ add_import_load_path(File.dirname(library))
89
+ require_in_box(library)
88
90
  share_error_classes
89
91
  self
90
92
  end
91
93
 
92
- # Activate the caller's gems for the target, then load it inside the box.
94
+ # Activate the Importer's gems for the target, then load it in this Box.
93
95
  def load_import(feature, load_path: nil, gems: [], locked_gems: nil)
94
96
  activate_gems(gems, locked_gems) unless gems.empty?
95
97
  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
99
-
100
98
  @entrypoint = real_path(feature)
101
- result = require_in_box(feature)
102
- discover_public_entries
103
- result
99
+ require_in_box(feature)
104
100
  end
105
101
 
106
102
  attr_reader :import_cache, :pending_gems
@@ -125,7 +121,7 @@ module Carton
125
121
  # Box methods run in the Box that defined them, so this Box's own copy of
126
122
  # Carton activates gems in this Box's RubyGems. `$LOAD_PATH` resolves to
127
123
  # 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.
124
+ # Importer, so activation runs from a file required here.
129
125
  def activate_gems(gems, locked_gems)
130
126
  @pending_gems = [gems, locked_gems]
131
127
  require_in_box(ACTIVATE_GEMS)
@@ -135,7 +131,7 @@ module Carton
135
131
 
136
132
  # This Box just loaded its own copy of Carton, whose error classes are new
137
133
  # objects that `rescue Carton::Error` in another Box would never match.
138
- # Point them at the importer's classes, so one rescue catches errors
134
+ # Point them at the Importer's classes, so one rescue catches errors
139
135
  # raised in any Carton.
140
136
  def share_error_classes
141
137
  carton = @box.const_get(:Carton, false)
@@ -150,7 +146,7 @@ module Carton
150
146
  load_path.unshift(path) unless load_path.include?(path)
151
147
  end
152
148
 
153
- # Only the imported Entrypoint declares this Carton's exports. Other files
149
+ # Only the imported Entrypoint declares this Carton's Export. Other files
154
150
  # it requires may call `export` for their own imports; here it is inert.
155
151
  def entrypoint?(file)
156
152
  !@entrypoint.nil? && real_path(file) == @entrypoint
@@ -170,40 +166,18 @@ module Carton
170
166
  @export = value
171
167
  end
172
168
 
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
178
- end
169
+ # Every Entrypoint deliberately chooses the value its Importers receive.
170
+ def exported_value
171
+ if NO_EXPORT.equal?(@export)
172
+ raise ExportError,
173
+ "#{@entrypoint} must call export: export a module or another value"
174
+ end
179
175
 
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]
176
+ @export
186
177
  end
187
178
 
188
179
  def reset_export
189
180
  @export = NO_EXPORT
190
181
  end
191
-
192
- def snapshot_import_support
193
- @import_constant_names = @box.constants(false).map(&:to_s)
194
- @import_method_names = top_level_methods.keys
195
- end
196
-
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)
201
- end
202
-
203
- def top_level_methods
204
- @box.eval(
205
- 'Object.private_instance_methods(false).to_h { |name| [name.to_s, method(name)] }',
206
- )
207
- end
208
182
  end
209
183
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Carton
4
4
  class << self
5
- # Set up the current carton's bundle, like `Bundler.setup(*groups)`.
5
+ # Set up the current Carton's bundle, like `Bundler.setup(*groups)`.
6
6
  #
7
7
  # Bundler finds its Gemfile through process-global `ENV["BUNDLE_GEMFILE"]`
8
8
  # or the real process cwd. Carton resolves the Gemfile from the calling
data/lib/carton/error.rb CHANGED
@@ -8,13 +8,12 @@ module Carton
8
8
  end
9
9
 
10
10
  # An Import that cannot go ahead: Boxes are disabled, a Carton imports
11
- # itself, or a cached Carton was built against other gem versions.
11
+ # itself, or a shared Carton was built against other gem versions.
12
12
  class ImportError < Error
13
13
  end
14
14
 
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.
15
+ # An Entrypoint finished without exporting, called `export` twice, or
16
+ # gave it a Symbol.
18
17
  class ExportError < Error
19
18
  end
20
19
 
@@ -1,23 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carton
4
- # Carries the caller's activated gems into a Carton Box.
4
+ # Carries the Importer's activated gems into a Carton Box.
5
5
  #
6
6
  # Optional Boxes copy Master, so they start with clean RubyGems state and
7
7
  # would auto-activate the newest installed version of any dependency. When
8
8
  # an Import resolves inside an activated gem, Carton activates that gem and
9
- # its runtime dependencies in the new Box at the caller's versions, so the
10
- # caller's lockfile (or plain `gem` calls) decides what the Carton sees.
9
+ # its runtime dependencies in the new Box at the Importer's versions, so the
10
+ # Importer's lockfile (or plain `gem` calls) decides what the Carton sees.
11
11
  #
12
- # When the caller ran Bundler setup, the Box is also restricted to the
13
- # caller's locked gems, as `Bundler.setup` restricts Main: every locked gem
12
+ # When the Importer ran Bundler setup, the Box is also restricted to the
13
+ # Importer's locked gems, as `Bundler.setup` restricts Main: every locked gem
14
14
  # is activated at its locked version and anything else fails to load.
15
15
  module GemActivation
16
16
  module_function
17
17
 
18
- # The gem in `loaded_specs` that owns `feature`, plus its activated
19
- # runtime dependencies, dependencies first. Default gems count: a new Box
20
- # would otherwise load the newest installed version, not the caller's.
18
+ # The Gem Closure of `feature`: the gem in `loaded_specs` that owns it,
19
+ # plus its activated runtime dependencies, dependencies first. Default
20
+ # gems count: a new Box would otherwise load the newest installed version,
21
+ # not the Importer's.
21
22
  # Empty when no activated gem owns the file.
22
23
  def closure(feature, loaded_specs)
23
24
  owner = owner_of(feature, loaded_specs.values)
@@ -26,7 +27,7 @@ module Carton
26
27
  visit(owner, loaded_specs, [], {})
27
28
  end
28
29
 
29
- # The caller's locked gems when `box` ran Bundler setup, otherwise nil.
30
+ # The Importer's locked gems when `box` ran Bundler setup, otherwise nil.
30
31
  # Bundler marks every locked spec as loaded, so these are the shareable
31
32
  # entries of its `Gem.loaded_specs`.
32
33
  def locked(box, loaded_specs)
@@ -57,7 +58,7 @@ module Carton
57
58
  #
58
59
  # Default gems come first, from this Box's own default specs: the YAML
59
60
  # round trip loads Psych, whose requires (date, for one) would otherwise
60
- # activate the newest installed version before the caller's.
61
+ # activate the newest installed version before the Importer's.
61
62
  def activate(specs, locked = nil)
62
63
  defaults, others = (locked || specs).partition(&:default_gem?)
63
64
  defaults.each { |source| activate_spec(default_spec(source)) }
@@ -138,8 +139,8 @@ module Carton
138
139
  private_class_method :visit
139
140
 
140
141
  # Bundler is never shared: each Box loads its own copy to apply the
141
- # restriction, possibly a different version than the caller's, and
142
- # activating the caller's on top of it raises Gem::LoadError.
142
+ # restriction, possibly a different version than the Importer's, and
143
+ # activating the Importer's on top of it raises Gem::LoadError.
143
144
  def activatable?(spec) = spec.name != 'bundler'
144
145
  private_class_method :activatable?
145
146
 
@@ -6,9 +6,7 @@ module Carton
6
6
  # The Main Box creates one instance and every Carton Box receives that same
7
7
  # object, so each Entrypoint loads once no matter which Carton imports it.
8
8
  # Ruby objects are shared across Boxes; only definitions are Box-local, so
9
- # the Main Box's loader builds every Carton. A cached value is then a
10
- # value or `Carton::Exports` from the Main Box regardless of which
11
- # Carton imported it first.
9
+ # the Main Box's loader builds every Carton and caches its Export.
12
10
  class ImportCache
13
11
  # Returned by `claim` when the calling thread should load the Entrypoint.
14
12
  LOAD = Object.new.freeze
@@ -80,9 +78,9 @@ module Carton
80
78
  end
81
79
  end
82
80
 
83
- # A cached Carton keeps the gem versions its first importer gave it: its
84
- # closure and the locked gems it has loaded, including any loaded lazily
85
- # since. Raise instead of silently sharing it with an importer that
81
+ # A shared Carton keeps the gem versions its First Importer gave it: its
82
+ # Gem Closure and the locked gems it has loaded, including any loaded lazily
83
+ # since. Raise instead of silently sharing it with an Importer that
86
84
  # activates or locks another version of one.
87
85
  def check_gem_versions(key, target)
88
86
  wanted = versions([*target.locked_gems, *target.gems])
@@ -4,19 +4,19 @@ module Carton
4
4
  module KernelPatch
5
5
  private # like `require` and `require_relative`
6
6
 
7
- # Import a carton by absolute path or by feature name from the caller box's
7
+ # Import a Carton by absolute path, or by feature name from the Importer's
8
8
  # current `$LOAD_PATH`.
9
9
  def import(path)
10
10
  Runtime.import(path, from: caller_locations(1, 1).first.path)
11
11
  end
12
12
 
13
- # Import a carton relative to the calling file, like `require_relative`.
13
+ # Import a Carton relative to the calling file, like `require_relative`.
14
14
  def import_relative(path)
15
15
  caller_dir = File.dirname(caller_locations(1, 1).first.path)
16
16
  Runtime.import(path, base_dir: caller_dir)
17
17
  end
18
18
 
19
- # Export one value from the current carton; `import` returns it as is.
19
+ # Export one value from the current Carton; `import` returns it as is.
20
20
  # Keywords export a Hash, as in `export version: '1.0'`.
21
21
  def export(value)
22
22
  file = caller_locations(1, 1).first.absolute_path
@@ -2,16 +2,16 @@
2
2
 
3
3
  module Carton
4
4
  # Maps an Import path to the file that should load, without evaluating any
5
- # code or reading Box state. The runtime passes the caller Box's
5
+ # code or reading Box state. The runtime passes the Importer's
6
6
  # `$LOAD_PATH`; a static analyzer can pass any load path it has computed.
7
7
  module Resolver
8
- # `feature` is what the new Box requires. `load_path` is the single caller
8
+ # `feature` is what the new Box requires. `load_path` is the single Importer
9
9
  # load-path entry that matched a name-based Import, if any. The runtime
10
- # fills `gems` with the caller specs to activate in the new Box, and
11
- # `locked_gems` with the caller's locked specs when it ran Bundler setup.
10
+ # fills `gems` with the Gem Closure to activate in the new Box, and
11
+ # `locked_gems` with the Importer's locked specs when it ran Bundler setup.
12
12
  # After loading, the Carton Box sets `gem_usage`, a callable returning the
13
- # gems the Carton depends on at that moment: its closure plus every locked
14
- # gem whose files it has loaded so far.
13
+ # gems the Carton depends on at that moment: its Gem Closure plus every
14
+ # locked gem whose files it has loaded so far.
15
15
  Target =
16
16
  Struct.new(
17
17
  :feature,
@@ -2,8 +2,10 @@
2
2
 
3
3
  module Carton
4
4
  module Runtime
5
- ENTRYPOINT = File.expand_path('../carton.rb', __dir__)
6
- private_constant :ENTRYPOINT
5
+ # Carton's own library, which every Carton Box loads so its Entrypoint can
6
+ # call `import` and `export`. It is not an Entrypoint.
7
+ LIBRARY = File.expand_path('../carton.rb', __dir__)
8
+ private_constant :LIBRARY
7
9
  # Used only when this Box is the Main Box. Carton Boxes reuse the Main
8
10
  # Box cache, so every Carton is built by the Main Box loader.
9
11
  IMPORT_CACHE = ImportCache.new { |target| load_import(target) }
@@ -11,24 +13,22 @@ module Carton
11
13
 
12
14
  module_function
13
15
 
14
- # Load the target feature in a new box and return the exported value, or
15
- # a `Carton::Namespace` of its definitions if it exports nothing. Each Entrypoint
16
- # loads once per process; later imports return the same value.
17
- # `base_dir` is given for `import_relative`; plain `import` searches the
18
- # caller Box's `$LOAD_PATH`, and `from` names the calling file for its
19
- # error message.
16
+ # Load the target in a new Carton Box and return its Export. Each Entrypoint
17
+ # loads once per process; later imports return the same value. `base_dir` is
18
+ # given for `import_relative`; plain `import` searches the Importer's
19
+ # `$LOAD_PATH`, and `from` names the calling file for its error message.
20
20
  def import(path, base_dir: nil, from: nil)
21
21
  unless Ruby::Box.enabled?
22
22
  raise ImportError, 'Carton needs Ruby::Box: run Ruby with RUBY_BOX=1'
23
23
  end
24
24
 
25
- caller_box = Ruby::Box.current
26
- load_path = caller_box.eval('$LOAD_PATH.to_a')
25
+ importer_box = Ruby::Box.current
26
+ load_path = importer_box.eval('$LOAD_PATH.to_a')
27
27
  target = Resolver.resolve(path, load_path:, base_dir:)
28
- loaded_specs = caller_box.eval('defined?(Gem) ? Gem.loaded_specs : {}')
28
+ loaded_specs = importer_box.eval('defined?(Gem) ? Gem.loaded_specs : {}')
29
29
  target.gems = GemActivation.closure(target.feature, loaded_specs)
30
30
  unless target.gems.empty?
31
- target.locked_gems = GemActivation.locked(caller_box, loaded_specs)
31
+ target.locked_gems = GemActivation.locked(importer_box, loaded_specs)
32
32
  end
33
33
  begin
34
34
  import_cache.import(target)
@@ -39,7 +39,7 @@ module Carton
39
39
  end
40
40
  end
41
41
 
42
- # Make `value` the current carton's export, which `import` returns as
42
+ # Make `value` the current Carton's Export, which `import` returns as
43
43
  # is. A Symbol is refused even outside the Entrypoint: `export def name`
44
44
  # and `export :Name` would export the Symbol itself.
45
45
  def export(value, file:)
@@ -54,7 +54,7 @@ module Carton
54
54
  end
55
55
 
56
56
  # A name not on `$LOAD_PATH` is the usual mistake of `import` for a file
57
- # beside the caller, so point at `import_relative` when one is there.
57
+ # beside the calling file, so point at `import_relative` when one is there.
58
58
  def missing_name_message(path, message, from)
59
59
  message = "#{message}: import searches $LOAD_PATH, like require"
60
60
  dir = from && File.dirname(File.expand_path(from))
@@ -68,14 +68,12 @@ module Carton
68
68
  private_class_method :missing_name_message
69
69
 
70
70
  def load_import(target)
71
- Box
72
- .new
73
- .__send__(
74
- :import_target,
75
- target,
76
- carton_entrypoint: ENTRYPOINT,
77
- import_cache: IMPORT_CACHE,
78
- ) { |methods, constants| Namespace.new(methods, constants) }
71
+ Box.new.__send__(
72
+ :import_target,
73
+ target,
74
+ library: LIBRARY,
75
+ import_cache: IMPORT_CACHE,
76
+ )
79
77
  end
80
78
  private_class_method :load_import
81
79
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Carton
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/carton.rb CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  require_relative 'carton/error'
4
4
  require_relative 'carton/bundler'
5
- require_relative 'carton/namespace_methods'
6
- require_relative 'carton/namespace'
7
5
  require_relative 'carton/box'
8
6
  require_relative 'carton/gem_activation'
9
7
  require_relative 'carton/import_cache'
@@ -16,7 +14,6 @@ module Carton
16
14
  private_constant :GemActivation
17
15
  private_constant :ImportCache
18
16
  private_constant :KernelPatch
19
- private_constant :NamespaceMethods
20
17
  private_constant :Resolver
21
18
  private_constant :Runtime
22
19
  end
data/sig/carton.rbs CHANGED
@@ -23,27 +23,3 @@ end
23
23
 
24
24
  class Carton::GemfileNotFound < Carton::Error
25
25
  end
26
-
27
- # Lookup helpers on every `Carton::Namespace`. `carton rbs` extends its
28
- # generated `Carton::Namespace::*` modules with these.
29
- module Carton::NamespaceMethods
30
- type key = ::String | ::Symbol
31
-
32
- # Helpers come back as Method objects; constants as set.
33
- def []: (key key) -> untyped
34
- def deconstruct_keys: (nil keys) -> ::Hash[::Symbol, untyped]
35
- | (::Array[::Symbol] keys) -> ::Hash[::Symbol, untyped]
36
- def fetch: (key key) -> untyped
37
- | (key key, untyped default) -> untyped
38
- | [T] (key key) { (key key) -> T } -> (untyped | T)
39
- def fetch_values: (*key keys) -> ::Array[untyped]
40
- def keys: () -> ::Array[::Symbol]
41
- def to_h: () -> ::Hash[::Symbol, untyped]
42
- def has_key?: (key key) -> bool
43
- def key?: (key key) -> bool
44
- def values_at: (*key keys) -> ::Array[untyped]
45
- end
46
-
47
- class Carton::Namespace < ::Module
48
- include ::Carton::NamespaceMethods
49
- end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carton
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cristofaro
@@ -9,10 +9,11 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: The package system Ruby has been missing. Split code into Cartons that
13
- import and export like modules. Each Carton runs in its own Ruby::Box, so only what
14
- it exports is reachable, monkey patches stay where they are made, and two Cartons
15
- can use different versions of the same gem. No manifest needed. Requires Ruby 4.0.7+.
12
+ description: Split your app into Cartons with explicit imports and exports. Each has
13
+ its own dependencies and runs in a separate Ruby::Box, so Ruby enforces the boundaries
14
+ at runtime. Only what a Carton exports is reachable, monkey patches stay where they
15
+ are made, and two Cartons can use different versions of the same gem. No manifest
16
+ needed. Requires Ruby 4.0.7+.
16
17
  email:
17
18
  - david@dtcristo.com
18
19
  executables: []
@@ -31,8 +32,6 @@ files:
31
32
  - lib/carton/import_cache.rb
32
33
  - lib/carton/kernel_patch.rb
33
34
  - lib/carton/loaded_features.rb
34
- - lib/carton/namespace.rb
35
- - lib/carton/namespace_methods.rb
36
35
  - lib/carton/resolver.rb
37
36
  - lib/carton/runtime.rb
38
37
  - lib/carton/version.rb
@@ -1,78 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Carton
4
- # What importing a Bare Carton returns: a module of the constants and
5
- # top-level helpers its Entrypoint defined. Helpers become singleton
6
- # methods that forward to the Carton's method, and lookup helpers return
7
- # the `Method` object or the constant.
8
- class Namespace < ::Module
9
- include NamespaceMethods
10
-
11
- # A helper becomes a singleton method, so one named like a method every
12
- # `Carton::Namespace` has, such as `fetch` or `name`, would hide it.
13
- # Constants are capitalized and never collide.
14
- def self.reserved?(name)
15
- name = name.to_s
16
- !name.match?(/\A[A-Z]/) && RESERVED.include?(name)
17
- end
18
-
19
- # Raise for the first reserved name in `names`.
20
- def self.check_names(names)
21
- name = names.find { |n| reserved?(n) } or return
22
-
23
- raise ExportError,
24
- "a top-level method named #{name} would hide " \
25
- "Carton::Namespace##{name}: rename it, or export a module"
26
- end
27
-
28
- def initialize(methods = {}, constants = {})
29
- super()
30
- Namespace.check_names(methods.keys)
31
- @lookup = {}
32
- define_methods(methods)
33
- define_constants(constants)
34
- end
35
-
36
- def deconstruct_keys(keys)
37
- return super if keys
38
-
39
- to_h
40
- end
41
-
42
- # `#<Carton::Namespace PI, add>`: its names, so a namespace is
43
- # recognizable in a console.
44
- def inspect
45
- names = @lookup.keys.join(', ')
46
- names.empty? ? '#<Carton::Namespace>' : "#<Carton::Namespace #{names}>"
47
- end
48
- alias to_s inspect
49
-
50
- private
51
-
52
- def define_methods(methods)
53
- methods.each do |name, method|
54
- @lookup[name.to_s] = method
55
- define_singleton_method(name) do |*args, **kwargs, &block|
56
- method.call(*args, **kwargs, &block)
57
- end
58
- end
59
- end
60
-
61
- def define_constants(constants)
62
- constants.each do |name, value|
63
- @lookup[name.to_s] = value
64
- const_set(name, value)
65
- end
66
- end
67
-
68
- def lookup_entry(key)
69
- name = key.to_s
70
- @lookup.key?(name) ? [true, @lookup[name]] : [false, nil]
71
- end
72
-
73
- def lookup_names = @lookup.keys
74
-
75
- RESERVED = public_instance_methods.map(&:to_s).freeze
76
- private_constant :RESERVED
77
- end
78
- end
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Carton
4
- # Hash-like lookup helpers for `Carton::Namespace`.
5
- module NamespaceMethods
6
- # Support destructuring like `import('math') => { add: }`.
7
- def deconstruct_keys(keys)
8
- keys.each_with_object({}) do |key, hash|
9
- found, value = lookup_entry(key)
10
- hash[key] = value if found
11
- end
12
- end
13
-
14
- # Return the member or `nil`.
15
- def [](key)
16
- found, value = lookup_entry(key)
17
- found ? value : nil
18
- end
19
-
20
- # Check whether the lookup will succeed.
21
- def key?(key)
22
- lookup_entry(key).first
23
- end
24
-
25
- alias has_key? key?
26
-
27
- # Fetch a member with the same default/block semantics as `Hash#fetch`.
28
- def fetch(key, *default)
29
- if default.size > 1
30
- raise ArgumentError,
31
- "wrong number of arguments (given #{default.size + 1}, expected 1..2)"
32
- end
33
-
34
- found, value = lookup_entry(key)
35
- return value if found
36
- return yield key if block_given?
37
- return default.first unless default.empty?
38
-
39
- raise KeyError, "key not found: #{key.inspect}"
40
- end
41
-
42
- # Member names as Symbols: helpers first, then constants.
43
- def keys = lookup_names.map(&:to_sym)
44
-
45
- # Every member by name: helpers as `Method` objects, constants as set.
46
- def to_h = keys.to_h { |key| [key, self[key]] }
47
-
48
- # Fetch several members and raise if any are missing.
49
- def fetch_values(*keys)
50
- keys.map { |key| fetch(key) }
51
- end
52
-
53
- # Return several members, using `nil` for missing keys.
54
- def values_at(*keys)
55
- keys.map { |key| self[key] }
56
- end
57
- end
58
- end