Skip to content

Add Gemfile override DSL for version constraints (Phase 1) - #9517

Merged
hsbt merged 15 commits into
masterfrom
override-dsl
May 7, 2026
Merged

hsbt merged 15 commits into
masterfrom
override-dsl

Conversation

@hsbt

@hsbt hsbt commented May 1, 2026 •

Copy link
Copy Markdown
Member

What was the end-user or developer problem that led to this PR?

#9494, Supersedes #9454.

What is your fix for the problem, implemented in this PR?

Adds a Gemfile-level override directive that rewrites a gem's version requirement before resolution. Phase 1 covers per-gem version: only.

override "rails",   version: ">= 8.0"      # absolute replacement
override "rails",   version: :ignore_upper # strip <, <=, fold ~> into >=
override "legacy",  version: nil           # strip the requirement entirely

Applies to direct and transitive deps and against an existing lockfile. Gemfile.lock keeps recording resolved versions, not the rewrite.

Phase 2 will add :all and metadata fields:

override :all,      required_ruby_version: :ignore_upper
override "old_gem", required_ruby_version: :ignore_upper
override "old_gem", required_rubygems_version: :ignore_upper

Make sure the following tasks are checked

hsbt and others added 15 commits May 1, 2026 16:36
Introduce a value object that holds a target/field/operation triple
for the upcoming Gemfile `override` DSL. apply_to dispatches on the
operation: a version spec string replaces the requirement absolutely,
:ignore_upper strips < and <= while folding ~> into >=, and nil
collapses to Gem::Requirement.default. The :ignore_upper logic is
taken from Shopify/bundler-ignore-dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reserve a slot on Definition for the upcoming Gemfile `override` DSL.
This commit only stores the data; the DSL entry point and the resolver
hookup come in later commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce Gemfile-level `override target, field: operation, ...` that
collects Bundler::Override instances and forwards them to Definition
via to_definition. Validation and resolver hookup come in later
commits; this commit only wires the entry point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch remove_upper_bounds from a lower-bound allow-list to an
upper-bound reject-list so that operators other than <, <=, and ~>
are kept verbatim. The previous logic, inherited from
Shopify/bundler-ignore-dependency, dropped != exclusions and could
silently re-allow versions the user had explicitly pinned out
(e.g. >= 1.0, != 1.5.0, < 2.0 collapsed to >= 1.0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Version requirements are per-gem and have no meaning relative to the
`:all` target. Raise ArgumentError at the DSL entry point and store
nothing when this combination is given, even if other valid fields
are mixed in the same call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reject anything other than :all or a String for the target,
fields outside `:version`, and operations that are not a String,
nil, or one of the supported Symbol values (currently only
:ignore_upper). Validation runs before any Override is recorded so
that a multi-field call with an invalid entry leaves the DSL state
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two override statements that target the same gem and the same field
make it ambiguous which operation should win. Raise ArgumentError
when the new (target, field) pair already exists in the recorded
overrides, leaving the previously recorded entry untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass the Definition's overrides through to Resolver::Base and rewrite
each dependency's requirement at the entry of to_dependency_hash so
both direct and transitive deps are reshaped before PubGrub sees them.
The hook is the same point Shopify/bundler-ignore-dependency uses,
since prepare_dependencies and the @cached_dependencies closure both
funnel through to_dependency_hash. Override#apply_to handles all
three operation kinds, so :ignore_upper and nil also start working
from this commit; integration coverage for those paths follows in
later commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Resolver-only hook reshapes deps just before PubGrub sees them,
but it does not influence the Bundler::Dependency objects fed into
Definition#expanded_dependencies. As a result the
Resolver::Package built from each direct dep keeps the original
requirement, so its prerelease policy and (in later commits) lockfile
change detection ignore the override entirely. Apply the override to
direct deps at expanded_dependencies as well so that Package metadata
and convergence see the effective requirement; the Resolver hook
remains responsible for transitive deps fetched from gemspecs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
converge_dependencies compared the original Gemfile dependency
against the locked spec, so adding `override "foo", version: "= X"`
to a previously installed bundle did not register as a change.
additional_base_requirements_to_prevent_downgrades then kept
forwarding the locked version as a >= base requirement, which
intersected the override and made it a no-op. Pass overrides into
Definition.new positionally so they are available before the
constructor calls converge_dependencies, and compare each direct
dep's effective requirement (after override) to the locked spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the Gemfile dependency does not request a prerelease,
Resolver::Package's prerelease policy normally filters them out.
Because Definition#expanded_dependencies now feeds the effective
(override-applied) dep into Resolver::Base, an override that pins an
exact prerelease propagates into the package's prerelease decision
and the prerelease becomes selectable. Lock that contract in with an
integration test on a has_prerelease 1.0 / 1.1.pre fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verify end-to-end that override(..., version: :ignore_upper) drops
< / <= bounds and folds ~> into >= so a Gemfile-pinned 0.9.1 ceiling
no longer prevents myrack 1.0.0 from being chosen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verify end-to-end that override(..., version: nil) collapses the
requirement to Gem::Requirement.default for both direct deps (a
Gemfile pin to 0.9.1 is removed) and transitive deps (a
myrack_middleware-imposed = 0.9.1 floor is removed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an OVERRIDE section between GEMSPEC and SOURCE PRIORITY that
covers the syntax, the three operations (version spec string,
:ignore_upper, nil), and the lockfile-vs-resolution boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
converge_dependencies only iterates @Dependencies (Gemfile-declared
direct deps), so an override that targets a gem present only as a
transitive dependency never registered as a change. With an existing
lockfile, @dependency_changes stayed false, the resolver was skipped,
and the override was a silent no-op. After the direct-dep loop,
inspect @OVERRIDES for any String target that is locked but not a
direct dep and force it onto @gems_to_unlock / @changed_dependencies
so resolution runs and the Resolver-side override hook applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hlascelles

Copy link
Copy Markdown
Contributor

Thank you @hsbt ! Looks like this will go a long way to resolving the issue. I will give it a go when available.

Just checking though, this "rewrites the constraint on a gem" - that is slightly more forceful than bundler-resolutions does.

If a gem says it must use at least 1.2.3 of a dependency, bundler-resolutions doesn't replace it, it just augments it to say "AND ALSO greater than 1.2.5". This way we are still "guaranteed" that the dependent gem should work given its own constraints.

I can see how the new override system is more powerful though - but potentially riskier to use.

Can we make it an option in the new system to just add constraints? Maybe:

override "legacy", include: "> 1.2.5"

@hsbt

hsbt commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

I'm not sure I understand the problem. If you want legacy above 1.2.5 while keeping the bounds its dependents declare, gem "legacy", "> 1.2.5", require: false in the Gemfile already does that, since Bundler combines it with every other requirement on that gem.

Do you have a different goal in mind?

@hlascelles

Copy link
Copy Markdown
Contributor

That would bring in legacy as a concrete dependency, so also wouldn't be a good solve.

Since this PR is closed I will create an issue that describes this. 👍

@hlascelles

Copy link
Copy Markdown
Contributor

Opened as #9903

@byroot

byroot commented Oct 5, 2026

Copy link
Copy Markdown
Member

@hsbt maybe I missed something, but does override support NOT installing a gem?

e.g. I use gem A and it has a problematic dependency on B and I don't want to install B at all. Can I?

@hsbt

hsbt commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

No, override only rewrites requirements (version:, required_ruby_version: and required_rubygems_version:). version: nil drops the constraint on B, but B is still resolved and installed.

Is your case something like A depending on adapters it only loads optionally? If so, I can't name a gem offhand, but I've wanted to do that too. I'm thinking of a DSL like the following, which drops only the dependency from A, so B is still installed when another gem needs it. Does it match what you have in mind?

override "B", from: "A", version: :remove

@byroot

byroot commented Oct 5, 2026

Copy link
Copy Markdown
Member

My use case is two folds.

One is a gem having a dependency used for only parts of its functionality that I have no use for.

The other is to be able to substitute a gem for another. For example gem A depends on say grpc, but grpc is terrible and I happen to have grpc-lite or something, with the exact same API, I want to use that one instead.

It was all listed in https://byroot.github.io/ruby/bundler/2026/04/20/bundle-features.html

@hsbt

hsbt commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Understood. I'd like to keep the scope small, so override will only act on A's dependency on B. That covers both of your cases:

override "B", from: "A", to: nil  # A no longer depends on B
override "B", from: "A", to: "C"  # A depends on C instead of B

This replaces the version: :remove I suggested above.

@byroot

byroot commented Oct 5, 2026

Copy link
Copy Markdown
Member

Sounds good. Thanks again!

hsbt added a commit that referenced this pull request Oct 5, 2026
Some gems depend on a gem that only one of their features loads, or on a gem that an API-compatible one can stand in for. `override "B", from: "A", to: nil` drops the dependency of A on B, and `to: "C"` makes A depend on C instead.

The lockfile keeps every dependency as published, the same way `version:` overrides leave it, and the completeness checks, the installer and the source lookup apply the override instead.

#9517 (comment)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
matzbot pushed a commit to ruby/ruby that referenced this pull request Oct 6, 2026
Some gems depend on a gem that only one of their features loads, or on a gem that an API-compatible one can stand in for. `override "B", from: "A", to: nil` drops the dependency of A on B, and `to: "C"` makes A depend on C instead.

The lockfile keeps every dependency as published, the same way `version:` overrides leave it, and the completeness checks, the installer and the source lookup apply the override instead.

ruby/rubygems#9517 (comment)

ruby/rubygems@231ecdf564

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants