From ead07433104ff0e1476ef33a58ddca275163ffae Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 14 Nov 2026 22:49:19 +0100 Subject: [PATCH 1/3] fix(config): cap prism below 2 in the ractorize bundle A fresh resolve of Gemfile.ractorize picks prism 2 now that minitest 6.1 no longer caps it. refract 1.1.0, which Phlex loads, references Prism::MissingNode, which prism 2 removed, so a Phlex app's ractorize job failed at boot. The cap is added to the repo's own prism requirement, or declared when the repo has none, until refract releases yippee-fun/refract#4 (yippee-fun/refract#6). Claude-Session: https://claude.ai/code/session_01E7i6yCPUYAKuVY9xXuKLf6 --- CHANGELOG.md | 4 ++ README.md | 3 +- config/ractorize.gemfile | 20 ++++++++-- .../conformance_kit/ractorize_gemfile_spec.rb | 40 +++++++++++++++++-- 4 files changed, 59 insertions(+), 8 deletions(-) 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..d9ccc78 100644 --- a/config/ractorize.gemfile +++ b/config/ractorize.gemfile @@ -2,24 +2,38 @@ # 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_key { |name| gem name unless capped.include?(name) } 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 From c5c62394b1b3c519690e22e7d4f6100d725be9d6 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 14 Nov 2026 22:59:15 +0100 Subject: [PATCH 2/3] fix(config): keep a gemspec's own prism floor on Bundler 2.6.5+ Bundler 2.6.5 to 2.7 add a gemspec's development dependencies without calling +gem+, so the cap never saw them and the final `gem "prism", "< 2"` replaced the dev dependency, dropping its own requirement (Ruby 3.4.11 CI: ["< 2"] instead of [">= 1.4", "< 2"]). The cap now carries the requirement already declared for the gem. Claude-Session: https://claude.ai/code/session_01E7i6yCPUYAKuVY9xXuKLf6 --- config/ractorize.gemfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config/ractorize.gemfile b/config/ractorize.gemfile index d9ccc78..8165753 100644 --- a/config/ractorize.gemfile +++ b/config/ractorize.gemfile @@ -36,4 +36,10 @@ eval_gemfile File.expand_path("Gemfile", __dir__) reading_repo_gemfile = false ractorize_sources.each { |name, source| gem name, **source } -ractorize_caps.each_key { |name| gem name unless capped.include?(name) } +ractorize_caps.each do |name, cap| + next if capped.include?(name) + + # Bundler 2.7 adds a gemspec's development dependencies without calling +gem+: keep their requirement. + own = dependencies.find { _1.name == name }&.requirement&.as_list&.reject { _1 == ">= 0" } + gem name, *own, cap +end From 15d72ed134debc87345f5ab4cc4be319270a718b Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 14 Nov 2026 22:59:57 +0100 Subject: [PATCH 3/3] style(config): no 3-call safe navigation chain in the prism cap Claude-Session: https://claude.ai/code/session_01E7i6yCPUYAKuVY9xXuKLf6 --- config/ractorize.gemfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/config/ractorize.gemfile b/config/ractorize.gemfile index 8165753..e77bcc2 100644 --- a/config/ractorize.gemfile +++ b/config/ractorize.gemfile @@ -39,7 +39,8 @@ ractorize_sources.each { |name, source| gem name, **source } ractorize_caps.each do |name, cap| next if capped.include?(name) - # Bundler 2.7 adds a gemspec's development dependencies without calling +gem+: keep their requirement. - own = dependencies.find { _1.name == name }&.requirement&.as_list&.reject { _1 == ">= 0" } - gem name, *own, cap + # 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