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

Add Gemfile override DSL for version constraints (Phase 1)#9517
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>
@hsbt hsbt merged commit a4f8f38 into master May 7, 2026
97 checks passed
@hsbt hsbt deleted the override-dsl branch May 7, 2026 09:24
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.

1 participant