Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.5.1 - 2026-11-14

- `config/ractorize.gemfile` caps prism below 2, added to the repo's own prism requirement or declared when it has none. A fresh resolve picks prism 2 now that minitest 6.1 no longer caps it, and refract 1.1.0 (which Phlex loads) references `Prism::MissingNode`, which prism 2 removed, so a Phlex app's ractorize job failed at boot with `NameError: uninitialized constant Prism::MissingNode`. The cap goes once refract releases yippee-fun/refract#4 (yippee-fun/refract#6).

## 0.5.0 - 2026-10-18

- `conformance-audit --require` and `audit.require` take a gem or feature name as well as a path: an entry that is not written as a path (`./`, `../`, `~`, absolute) and names no existing file is required from the load path. The kit now audits any installed gem with no Gemfile change and no `.conformance.yml`: `gem install conformance-kit` then `conformance-audit --require loofah --namespace Loofah`. Existing paths resolve as before.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,9 @@ With Active Record, it also needs a dumped schema cache (`db:schema:cache:dump`)
- Rails main;
- Rack main, for `rack/ractorize`;
- Shopify's i18n branch, pinned to [Shopify/i18n#5](https://github.com/Shopify/i18n/pull/5) until that merges: without it, every render fails on `I18n.fallbacks`.
- prism below 2, until refract releases [yippee-fun/refract#4](https://github.com/yippee-fun/refract/pull/4): refract 1.1.0, which Phlex loads, references `Prism::MissingNode`, which prism 2 removed, so with prism 2 the app fails at boot.

`config/ractorize.gemfile` swaps them into your Gemfile. It drops your own lines for those gems and Rails' component gems (Bundler refuses a gem declared twice) and keeps everything else. Copy it next to your Gemfile and bundle with it, as the workflow does; keep `Gemfile.ractorize` and its lock out of git.
`config/ractorize.gemfile` swaps them into your Gemfile. It drops your own lines for those gems and Rails' component gems (Bundler refuses a gem declared twice), adds the prism cap to your own prism requirement (or declares it), and keeps everything else. Copy it next to your Gemfile and bundle with it, as the workflow does; keep `Gemfile.ractorize` and its lock out of git.

```bash
cp "$(bundle exec ruby -e 'print Gem.loaded_specs["conformance-kit"].full_gem_path')/config/ractorize.gemfile" Gemfile.ractorize
Expand Down
27 changes: 24 additions & 3 deletions config/ractorize.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,45 @@

# The ractorize job's bundle: the repo's own Gemfile, with Rails, Rack and i18n
# swapped for the branches `Rails.application.ractorize!` needs until they ship
# (Rails 8.2, Rack's rack/ractorize, ruby-i18n/i18n#741). Copy it next to the
# repo's Gemfile as Gemfile.ractorize and run with BUNDLE_GEMFILE=Gemfile.ractorize.
# (Rails 8.2, Rack's rack/ractorize, ruby-i18n/i18n#741), and the caps below.
# Copy it next to the repo's Gemfile as Gemfile.ractorize and run with
# BUNDLE_GEMFILE=Gemfile.ractorize.
ractorize_sources = {
"rails" => { github: "rails/rails", branch: "main" },
"rack" => { github: "rack/rack", branch: "main" },
"i18n" => { github: "Shopify/i18n", ref: "refs/pull/5/head" }
}.freeze
# Upper bounds a fresh resolve needs, added to the repo's own requirement for the gem.
# prism < 2: refract 1.1.0, which Phlex loads, references Prism::MissingNode, which
# prism 2 removed. Drop it once refract releases yippee-fun/refract#4 (yippee-fun/refract#6).
ractorize_caps = { "prism" => "< 2" }.freeze
# Rails' own gems come from the same checkout as rails.
replaced = %w[railties actionpack actionview activesupport activemodel activerecord activejob actionmailer
actioncable activestorage actiontext actionmailbox] + ractorize_sources.keys

# The repo's own lines for these gems are dropped: Bundler refuses a gem declared twice.
reading_repo_gemfile = true
capped = []
define_singleton_method(:gem) do |name, *args, **options, &block|
super(name, *args, **options, &block) unless reading_repo_gemfile && replaced.include?(name)
next if reading_repo_gemfile && replaced.include?(name)

if (cap = ractorize_caps[name])
capped << name
# A gemspec's development dependency passes its options as a trailing Hash: the cap goes before it.
args = args.dup.insert(args.index { _1.is_a?(Hash) } || args.size, cap)
end
super(name, *args, **options, &block)
end

eval_gemfile File.expand_path("Gemfile", __dir__)
reading_repo_gemfile = false

ractorize_sources.each { |name, source| gem name, **source }
ractorize_caps.each do |name, cap|
next if capped.include?(name)

# Bundler 2.6.5+ adds a gemspec's development dependencies without calling +gem+: keep their requirement.
own = dependencies.find { _1.name == name }
requirement = own ? own.requirement.as_list - [">= 0"] : []
gem name, *requirement, cap
end
40 changes: 36 additions & 4 deletions spec/conformance_kit/ractorize_gemfile_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@
let(:ractorize_gemfile) { File.expand_path("../../config/ractorize.gemfile", __dir__) }

# The dependencies Bundler reads from Gemfile.ractorize next to +repo_gemfile+ (no resolve, no network).
def dependencies(repo_gemfile, gemspec: nil)
def bundle_dependencies(repo_gemfile, gemspec: nil)
Dir.mktmpdir do |dir|
File.write(File.join(dir, "Gemfile"), repo_gemfile)
File.write(File.join(dir, "my_gem.gemspec"), gemspec) if gemspec
FileUtils.cp(ractorize_gemfile, File.join(dir, "Gemfile.ractorize"))
Bundler::Dsl.evaluate(File.join(dir, "Gemfile.ractorize"), File.join(dir, "Gemfile.ractorize.lock"), {})
.dependencies.to_h { [_1.name, _1.source] }
.dependencies
end
end

def dependencies(...) = bundle_dependencies(...).to_h { [_1.name, _1.source] }

def requirements(...) = bundle_dependencies(...).to_h { [_1.name, _1.requirement.as_list.sort] }

def github(source) = [source.uri, source.options["branch"] || source.options["ref"]]

it "drops the repo's own rails, rack and Rails component lines and declares the branches" do
Expand All @@ -29,7 +33,7 @@ def github(source) = [source.uri, source.options["branch"] || source.options["re
gem "rake"
RUBY

expect(sources.keys).to contain_exactly("rails", "rack", "i18n", "rake")
expect(sources.keys).to contain_exactly("rails", "rack", "i18n", "prism", "rake")
expect(github(sources.fetch("rails"))).to eq(["https://github.com/rails/rails.git", "main"])
expect(github(sources.fetch("rack"))).to eq(["https://github.com/rack/rack.git", "main"])
expect(github(sources.fetch("i18n"))).to eq(["https://github.com/Shopify/i18n.git", "refs/pull/5/head"])
Expand All @@ -51,7 +55,35 @@ def github(source) = [source.uri, source.options["branch"] || source.options["re

sources = dependencies(%(source "https://rubygems.org"\ngemspec\n), gemspec: gemspec)

expect(sources.keys).to contain_exactly("my_gem", "rails", "rack", "i18n")
expect(sources.keys).to contain_exactly("my_gem", "rails", "rack", "i18n", "prism")
expect(github(sources.fetch("rack"))).to eq(["https://github.com/rack/rack.git", "main"])
end

# refract 1.1.0, which Phlex loads, references Prism::MissingNode, which prism 2 removed.
it "caps prism below 2 when the repo does not declare it" do
expect(requirements(%(source "https://rubygems.org"\ngem "rake"\n))).to include("prism" => ["< 2"])
end

it "adds the cap to the repo's own prism requirement" do
repo_gemfile = %(source "https://rubygems.org"\ngem "prism", ">= 1.4", require: false\n)

expect(requirements(repo_gemfile).fetch("prism")).to eq(["< 2", ">= 1.4"])
end

it "adds the cap to a gemspec development dependency on prism" do
gemspec = <<~RUBY
Gem::Specification.new do |spec|
spec.name = "my_gem"
spec.version = "0.1.0"
spec.summary = "fixture"
spec.authors = ["fixture"]
spec.files = []
spec.add_development_dependency "prism", ">= 1.4"
end
RUBY

deps = bundle_dependencies(%(source "https://rubygems.org"\ngemspec\n), gemspec: gemspec)

expect(deps.select { _1.name == "prism" }.map { _1.requirement.as_list.sort }).to eq([["< 2", ">= 1.4"]])
end
end
Loading