From e94c6fae248f72082d0cc6d7896cd07f77acdb9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Tue, 4 Aug 2026 21:26:13 +0200 Subject: [PATCH] Serialize dependency constraints in lock entries --- lib/hex/mix.ex | 2 +- lib/hex/remote_converger.ex | 2 +- lib/hex/solver.ex | 14 +++++++-- lib/hex/solver/assignment.ex | 2 +- lib/hex/solver/constraint.ex | 3 +- lib/hex/solver/constraints/empty.ex | 6 +++- lib/hex/solver/constraints/impl.ex | 5 ++- lib/hex/solver/constraints/range.ex | 10 +++++- lib/hex/solver/constraints/union.ex | 6 +++- lib/hex/solver/constraints/util.ex | 2 +- lib/hex/solver/constraints/version.ex | 6 +++- lib/hex/solver/failure.ex | 2 +- lib/hex/solver/incompatibility.ex | 2 +- lib/hex/solver/package_lister.ex | 2 +- lib/hex/solver/package_range.ex | 2 +- lib/hex/solver/partial_solution.ex | 2 +- lib/hex/solver/registry.ex | 2 +- lib/hex/solver/requirement.ex | 26 ++++++++++++++-- lib/hex/solver/solver.ex | 2 +- lib/hex/solver/term.ex | 2 +- lib/hex/solver/util.ex | 2 +- test/hex/mix_test.exs | 45 +++++++++++++++++++++++++++ test/hex/remote_converger_test.exs | 35 +++++++++++++++++++++ 23 files changed, 158 insertions(+), 24 deletions(-) diff --git a/lib/hex/mix.ex b/lib/hex/mix.ex index 678891a3..a1749cc0 100644 --- a/lib/hex/mix.ex +++ b/lib/hex/mix.ex @@ -202,7 +202,7 @@ defmodule Hex.Mix do optional: optional, label: app }) do - {String.to_atom(app), to_string(constraint), + {String.to_atom(app), Hex.Solver.constraint_to_requirement!(constraint), hex: String.to_atom(name), repo: repo || "hexpm", optional: optional} end diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 3741ca67..cc9e538e 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -449,7 +449,7 @@ defmodule Hex.RemoteConverger do for %{repo: repo, name: name, constraint: req, optional: optional, label: app} <- deps do app = String.to_atom(app) opts = [optional: optional, hex: name, repo: repo] - {app, to_string(req), opts} + {app, Hex.Solver.constraint_to_requirement!(req), opts} end end diff --git a/lib/hex/solver.ex b/lib/hex/solver.ex index a8578668..8f8b96dd 100644 --- a/lib/hex/solver.ex +++ b/lib/hex/solver.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver do _ = """ @@ -26,7 +26,7 @@ defmodule Hex.Solver do @type result() :: %{package() => {Version.t(), repo()}} @opaque constraint() :: Hex.Solver.Requirement.t() - alias Hex.Solver.{Failure, Requirement, Solver} + alias Hex.Solver.{Constraint, Failure, Requirement, Solver} @doc """ Runs the version solver. @@ -85,4 +85,14 @@ defmodule Hex.Solver do def parse_constraint!(string) do Requirement.to_constraint!(string) end + + @doc """ + Serializes an internal solver constraint as an Elixir version requirement. + """ + @spec constraint_to_requirement!(constraint()) :: String.t() + def constraint_to_requirement!(constraint) do + requirement = Constraint.to_requirement(constraint) + Version.parse_requirement!(requirement) + requirement + end end diff --git a/lib/hex/solver/assignment.ex b/lib/hex/solver/assignment.ex index 2dcd573c..6d09fa1c 100644 --- a/lib/hex/solver/assignment.ex +++ b/lib/hex/solver/assignment.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Assignment do @moduledoc false diff --git a/lib/hex/solver/constraint.ex b/lib/hex/solver/constraint.ex index b543317b..94302d96 100644 --- a/lib/hex/solver/constraint.ex +++ b/lib/hex/solver/constraint.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defprotocol Hex.Solver.Constraint do @moduledoc false @@ -12,4 +12,5 @@ defprotocol Hex.Solver.Constraint do def intersect(left, right) def union(left, right) def compare(left, right) + def to_requirement(constraint) end diff --git a/lib/hex/solver/constraints/empty.ex b/lib/hex/solver/constraints/empty.ex index 3ac28e73..40896190 100644 --- a/lib/hex/solver/constraints/empty.ex +++ b/lib/hex/solver/constraints/empty.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Constraints.Empty do @moduledoc false @@ -36,6 +36,10 @@ defmodule Hex.Solver.Constraints.Empty do clauses: [] end + def to_requirement(%Empty{}) do + "< 0.0.0-0" + end + def to_string(%Empty{}) do "empty" end diff --git a/lib/hex/solver/constraints/impl.ex b/lib/hex/solver/constraints/impl.ex index e2c7d502..baf92c52 100644 --- a/lib/hex/solver/constraints/impl.ex +++ b/lib/hex/solver/constraints/impl.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Constraints.Impl do @moduledoc false @@ -34,6 +34,9 @@ defmodule Hex.Solver.Constraints.Impl do def compare(left, right), do: unquote(__CALLER__.module).compare(left, right) + + def to_requirement(constraint), + do: unquote(__CALLER__.module).to_requirement(constraint) end end end diff --git a/lib/hex/solver/constraints/range.ex b/lib/hex/solver/constraints/range.ex index a272471b..5b79085b 100644 --- a/lib/hex/solver/constraints/range.ex +++ b/lib/hex/solver/constraints/range.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Constraints.Range do @moduledoc false @@ -383,6 +383,14 @@ defmodule Hex.Solver.Constraints.Range do def normalize(%Range{} = range), do: range def normalize(%Elixir.Version{} = version), do: version + def to_requirement(%Range{min: nil, max: nil}) do + ">= 0.0.0-0" + end + + def to_requirement(%Range{} = range) do + Range.to_string(range) + end + def to_string(%Range{min: nil, max: nil}) do "any" end diff --git a/lib/hex/solver/constraints/union.ex b/lib/hex/solver/constraints/union.ex index 0d6609fb..e0c77af7 100644 --- a/lib/hex/solver/constraints/union.ex +++ b/lib/hex/solver/constraints/union.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Constraints.Union do @moduledoc false @@ -154,6 +154,10 @@ defmodule Hex.Solver.Constraints.Union do defp maybe_to_range(%Elixir.Version{} = version), do: Version.to_range(version) defp maybe_to_range(other), do: other + def to_requirement(%Union{ranges: ranges}) do + Enum.map_join(ranges, " or ", &Constraint.to_requirement/1) + end + def to_string(%Union{ranges: ranges}) do Enum.map_join(ranges, " or ", &Kernel.to_string/1) end diff --git a/lib/hex/solver/constraints/util.ex b/lib/hex/solver/constraints/util.ex index 0cc5fe7c..c9704566 100644 --- a/lib/hex/solver/constraints/util.ex +++ b/lib/hex/solver/constraints/util.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Constraints.Util do @moduledoc false diff --git a/lib/hex/solver/constraints/version.ex b/lib/hex/solver/constraints/version.ex index 8d86485a..c8c7ac4d 100644 --- a/lib/hex/solver/constraints/version.ex +++ b/lib/hex/solver/constraints/version.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Constraints.Version do @moduledoc false @@ -101,6 +101,10 @@ defmodule Hex.Solver.Constraints.Version do end end + def to_requirement(%Version{} = version) do + Kernel.to_string(version) + end + def max(left, right) do case compare(left, right) do :lt -> right diff --git a/lib/hex/solver/failure.ex b/lib/hex/solver/failure.ex index 65560abe..13c71c3e 100644 --- a/lib/hex/solver/failure.ex +++ b/lib/hex/solver/failure.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Failure do @moduledoc false diff --git a/lib/hex/solver/incompatibility.ex b/lib/hex/solver/incompatibility.ex index 831c2957..d6ce9c1b 100644 --- a/lib/hex/solver/incompatibility.ex +++ b/lib/hex/solver/incompatibility.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Incompatibility do @moduledoc false diff --git a/lib/hex/solver/package_lister.ex b/lib/hex/solver/package_lister.ex index 31b16aa3..b3bf2586 100644 --- a/lib/hex/solver/package_lister.ex +++ b/lib/hex/solver/package_lister.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.PackageLister do @moduledoc false diff --git a/lib/hex/solver/package_range.ex b/lib/hex/solver/package_range.ex index b004e3f2..edbbc7b0 100644 --- a/lib/hex/solver/package_range.ex +++ b/lib/hex/solver/package_range.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.PackageRange do @moduledoc false diff --git a/lib/hex/solver/partial_solution.ex b/lib/hex/solver/partial_solution.ex index 9e53a11a..b7360f6c 100644 --- a/lib/hex/solver/partial_solution.ex +++ b/lib/hex/solver/partial_solution.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.PartialSolution do @moduledoc false diff --git a/lib/hex/solver/registry.ex b/lib/hex/solver/registry.ex index bee82687..4b703cbd 100644 --- a/lib/hex/solver/registry.ex +++ b/lib/hex/solver/registry.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Registry do _ = """ diff --git a/lib/hex/solver/requirement.ex b/lib/hex/solver/requirement.ex index f2edddb5..8776d987 100644 --- a/lib/hex/solver/requirement.ex +++ b/lib/hex/solver/requirement.ex @@ -1,9 +1,9 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Requirement do @moduledoc false - alias Hex.Solver.Constraints.{Range, Util} + alias Hex.Solver.Constraints.{Empty, Range, Util} alias Hex.Solver.Requirement.Parser @allowed_range_ops [:>, :>=, :<, :<=, :~>] @@ -45,7 +45,9 @@ defmodule Hex.Solver.Requirement do end defp delex([], acc) do - Util.union(acc) + acc + |> Enum.map(&normalize_constraint/1) + |> Util.union() end defp delex([op | rest], acc) when op in [:||, :or] do @@ -146,6 +148,24 @@ defmodule Hex.Solver.Requirement do defp to_version({major, minor, patch, pre, _build}), do: %Elixir.Version{major: major, minor: minor, patch: patch, pre: pre} + defp normalize_constraint(%Range{ + max: %Elixir.Version{major: 0, minor: 0, patch: 0, pre: [0]}, + include_max: false + }) do + %Empty{} + end + + defp normalize_constraint( + %Range{ + min: %Elixir.Version{major: 0, minor: 0, patch: 0, pre: [0]}, + include_min: true + } = range + ) do + %{range | min: nil, include_min: false} + end + + defp normalize_constraint(constraint), do: constraint + # Vendored from https://github.com/elixir-lang/elixir/blob/0ff6522/lib/elixir/lib/version.ex#L495 defmodule Parser do @moduledoc false diff --git a/lib/hex/solver/solver.ex b/lib/hex/solver/solver.ex index 1d8bda8b..843429e7 100644 --- a/lib/hex/solver/solver.ex +++ b/lib/hex/solver/solver.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Solver do @moduledoc false diff --git a/lib/hex/solver/term.ex b/lib/hex/solver/term.ex index d6e2a1d5..19dc9c4f 100644 --- a/lib/hex/solver/term.ex +++ b/lib/hex/solver/term.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Term do @moduledoc false diff --git a/lib/hex/solver/util.ex b/lib/hex/solver/util.ex index 60996ff7..5c3e5df4 100644 --- a/lib/hex/solver/util.ex +++ b/lib/hex/solver/util.ex @@ -1,4 +1,4 @@ -# Vendored from hex_solver v0.2.3 (f702d44), do not edit manually +# Vendored from hex_solver v0.2.3 (291c624), do not edit manually defmodule Hex.Solver.Util do @moduledoc false diff --git a/test/hex/mix_test.exs b/test/hex/mix_test.exs index 83664ff2..a764d223 100644 --- a/test/hex/mix_test.exs +++ b/test/hex/mix_test.exs @@ -1,6 +1,39 @@ defmodule Hex.MixTest do use HexTest.Case + test "to_lock/1 persists unconstrained dependencies as valid requirements" do + Hex.Registry.Server.open() + put_dependency("unconstrained_parent", "1.0.0", ">= 0.5.50 or < 0.9.0", false) + + lock = + Hex.Mix.to_lock([ + {"hexpm", "unconstrained_parent", "unconstrained_parent", "1.0.0"} + ]) + + {:hex, :unconstrained_parent, "1.0.0", _inner_checksum, _managers, deps, "hexpm", + _outer_checksum} = lock[:unconstrained_parent] + + assert [{:foo, requirement, hex: :foo, repo: "hexpm", optional: false}] = deps + assert requirement == ">= 0.0.0-0" + assert {:ok, _requirement} = Version.parse_requirement(requirement) + assert Hex.Solver.parse_constraint!(requirement) == %Hex.Solver.Constraints.Range{} + end + + test "to_lock/1 persists empty optional dependencies as valid requirements" do + Hex.Registry.Server.open() + put_dependency("empty_parent", "1.0.0", "< 0.0.0-0", true) + + lock = Hex.Mix.to_lock([{"hexpm", "empty_parent", "empty_parent", "1.0.0"}]) + + {:hex, :empty_parent, "1.0.0", _inner_checksum, _managers, deps, "hexpm", _outer_checksum} = + lock[:empty_parent] + + assert [{:foo, requirement, hex: :foo, repo: "hexpm", optional: true}] = deps + assert requirement == "< 0.0.0-0" + assert {:ok, _requirement} = Version.parse_requirement(requirement) + assert Hex.Solver.parse_constraint!(requirement) == %Hex.Solver.Constraints.Empty{} + end + test "from_lock/1" do lock = [ex_doc: {:hex, :ex_doc, "0.1.0"}, postgrex: {:hex, :fork, "0.2.1"}] @@ -42,4 +75,16 @@ defmodule Hex.MixTest do Hex.Mix.from_lock(lock) refute_received ^message end + + defp put_dependency(package, version, requirement, optional) do + :sys.replace_state(Hex.Registry.Server, fn %{ets: tid, fetched: fetched} = state -> + :ets.insert(tid, [ + {{:inner_checksum, "hexpm", package, version}, <<0::256>>}, + {{:outer_checksum, "hexpm", package, version}, <<1::256>>}, + {{:deps, "hexpm", package, version}, [{"hexpm", "foo", "foo", requirement, optional}]} + ]) + + %{state | fetched: MapSet.put(fetched, {"hexpm", package})} + end) + end end diff --git a/test/hex/remote_converger_test.exs b/test/hex/remote_converger_test.exs index 5ab7b52c..f7785c1d 100644 --- a/test/hex/remote_converger_test.exs +++ b/test/hex/remote_converger_test.exs @@ -48,6 +48,41 @@ defmodule Hex.RemoteConvergerTest do end) end + test "deps/2 returns valid requirements for registry dependencies" do + Hex.Registry.Server.open() + package = "unconstrained_parent" + version = "1.0.0" + + :sys.replace_state(Hex.Registry.Server, fn %{ets: tid, fetched: fetched} = state -> + :ets.insert( + tid, + {{:deps, "hexpm", package, version}, + [ + {"hexpm", "foo", "foo", ">= 0.5.50 or < 0.9.0", false}, + {"hexpm", "bar", "bar", "< 0.0.0-0", true} + ]} + ) + + %{state | fetched: MapSet.put(fetched, {"hexpm", package})} + end) + + lock = %{unconstrained_parent: {:hex, :unconstrained_parent, version}} + deps = Hex.RemoteConverger.deps(%Mix.Dep{app: :unconstrained_parent}, lock) + + assert [ + {:foo, any_requirement, optional: false, hex: "foo", repo: nil}, + {:bar, empty_requirement, optional: true, hex: "bar", repo: nil} + ] = deps + + assert any_requirement == ">= 0.0.0-0" + assert {:ok, _requirement} = Version.parse_requirement(any_requirement) + assert Hex.Solver.parse_constraint!(any_requirement) == %Hex.Solver.Constraints.Range{} + + assert empty_requirement == "< 0.0.0-0" + assert {:ok, _requirement} = Version.parse_requirement(empty_requirement) + assert Hex.Solver.parse_constraint!(empty_requirement) == %Hex.Solver.Constraints.Empty{} + end + defmodule WarnOutdatedWithHexOption.MixProject do def project do [