diff --git a/CHANGELOG.md b/CHANGELOG.md index d76153a..37c796e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 9c062ee..fdb9cc4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/ractorize.gemfile b/config/ractorize.gemfile index 5481b45..e77bcc2 100644 --- a/config/ractorize.gemfile +++ b/config/ractorize.gemfile @@ -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 diff --git a/spec/conformance_kit/ractorize_gemfile_spec.rb b/spec/conformance_kit/ractorize_gemfile_spec.rb index c84c1b5..3a589ac 100644 --- a/spec/conformance_kit/ractorize_gemfile_spec.rb +++ b/spec/conformance_kit/ractorize_gemfile_spec.rb @@ -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 @@ -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"]) @@ -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