Skip to content
academia.sh

Lesson 02 / 09

Dependency Management

Why does the installed version not change while the lock file stays the same? The lesson measures which versions a version constraint accepts, shows that resolution without a lock file depends on the registry, and that the dependency is the determinant.

Published Updated

In this lesson

  • Measures which versions a pessimistic constraint, a range constraint and an exact version constraint accept
  • Shows that resolution without a lock file depends on the registry's contents and resolution with a lock file depends on the lock file
  • Separates that the installed version changes when the version in the lock file changes, and that this is the dependency determinant
  • Measures that the rule raises an error without classifying when the lock file does not satisfy the constraint
Contents

The previous lesson showed that the package’s file name comes from the name and version: that result was code. The package specification also declared a dependency, but the version to be installed was not written there. The question of this lesson is therefore one step further: why does the installed version not change as long as the lock file stays the same, and what happens when the lock file changes?

The question of the course is the same: what determines this result? The observation of this lesson is the locked version row. In M12/K02 the question was where a change reaches; there it was measured which layers a change spreads to. Here the question is reversed: which input does the installed version depend on? The core’s determinant.rb file is given again exactly; the block is source and is pulled in with require_relative.

# determinant.rb — a run model and a helper that classifies what determines a result

# Run: the three inputs a result may depend on. Everything outside these three is code.
Run = Data.define(:lock, :seed, :environment)

BASE = Run.new(
  lock: { "formatter" => "1.4.2", "parser" => "2.0.1" },
  seed: 1,
  environment: { clock: Time.utc(2026, 10, 16, 9, 0), cores: 4, variables: {} }
)

# Each variant changes a single input; the others stay as in the base.
VARIANTS = {
  dependency: BASE.with(lock: { "formatter" => "1.5.0", "parser" => "2.1.0" }),
  order: BASE.with(seed: 2),
  environment: BASE.with(environment: { clock: Time.utc(2027, 3, 1, 9, 0), cores: 8,
                                        variables: { "WORKER_COUNT" => "3" } })
}

# Rule: the computation runs first in the base, then once in each variant. The first variant
# that changes the result (dependency, order, environment, in that order) is the determinant;
# if none changes it, code.
def determinant(computation)
  base = computation.call(BASE)
  changed = VARIANTS.to_h { |name, run| [name, computation.call(run) != base] }
  kind = changed.key(true)&.to_s || "code"
  evidence = changed.map { |name, c| "#{name[0].upcase}#{c ? '+' : '-'}" }.join(" ")
  [kind, evidence, base]
end

Version Constraint

A dependency manager resolves which versions a package will work with through a version constraint. A constraint is not a version but an accepted range. Three forms are seen. An exact version constraint accepts a single version. A range constraint accepts what lies between two bounds: >= 1.4 and < 3 are written together. The third is the pessimistic constraint: the notation ~> 1.4 means “not less than 1.4, but with the major version unchanged”. The constraint allows the last written digit to rise and does not allow the digit before it to rise; for ~> 1.4 the upper bound is 2.

The file below applies the three forms to the same version list. Gem::Requirement builds the constraint and satisfied_by? says whether a version is accepted.

# constraints.rb — which versions a version constraint accepts
def accepted(constraints, versions)
  requirement = Gem::Requirement.new(*constraints)
  versions.select { |v| requirement.satisfied_by?(Gem::Version.new(v)) }
end

list = %w[1.3.9 1.4.0 1.4.2 1.5.0 1.99 2.0.0]
p accepted(["~> 1.4"], list)
p accepted(["~> 1.4.0"], list)
p accepted([">= 1.4", "< 3"], %w[1.3.9 1.4.0 2.9 3.0])

dependency = Gem::Dependency.new("formatter", "~> 1.4")
p dependency.match?("formatter", "1.9.0")
p dependency.match?("formatter", "2.0.0")
["1.4.0", "1.4.2", "1.5.0", "1.99"]
["1.4.0", "1.4.2"]
["1.4.0", "2.9"]
true
false

~> 1.4 keeps the major version at 1 and accepts everything up to 1.99: the minor version may rise. ~> 1.4.0 is one digit longer and the accepted range narrows: only 1.4.0 and 1.4.2. The last digit determines the width of the constraint. The range constraint is the intersection of two bounds and leaves 3.0 out. Gem::Dependency carries the name and the constraint together; it accepts 1.9.0 and rejects 2.0.0. If the semantic versioning contract holds, no incompatible change arrives as long as the major version stays the same; but the author of the package gives this contract, and the constraint does not check it. PG6. This measurement shows which versions the constraint accepts; it does not show that an accepted version is actually compatible.

Resolution Without a Lock

When a constraint accepts more than one version, the manager has to pick one. The simplest rule is to take the highest matching version. The pure Ruby helper below is a small model of this resolution.

# resolution.rb — the highest matching version from the registry when there is no lock file
def highest_matching(registry, constraint)
  requirement = Gem::Requirement.new(constraint)
  registry.map { |v| Gem::Version.new(v) }.select { |v| requirement.satisfied_by?(v) }.max.to_s
end

The helper is source. The file below applies it first to a registry of three versions and then to the registry after one version is added.

# registry.rb — the same constraint, a changed registry
require_relative "resolution"

registry = %w[1.4.2 1.5.0 2.0.0]
p highest_matching(registry, "~> 1.4")

registry << "1.6.0"
p highest_matching(registry, "~> 1.4")
"1.5.0"
"1.6.0"

The constraint stayed the same, the registry changed, and the result moved from "1.5.0" to "1.6.0". Resolution without a lock file depends on the registry’s contents at that moment. A machine installed yesterday and a machine installed today can receive different versions with the same constraint. This is one of the sources of the question “it worked on my machine”.

The Lock File

The remedy is to write the result of resolution to a lock file. The lock file holds the fully resolved versions of the constraints in the manifest. Every later installation does not resolve but reads the version in the lock file. The lock file is committed to the repository, because that is the only way to make all machines receive the same versions.

The core’s installed_version is not a dependency resolver but a model that reads the version in the lock file. The file below takes its definition exactly.

# dependency.rb — a model that reads the version in the lock file
# Dependency resolution: if the version in the lock satisfies the constraint, that version is installed.
def installed_version(run, name, constraint)
  version = Gem::Version.new(run.lock.fetch(name))
  raise ArgumentError, "lock does not satisfy constraint" unless Gem::Requirement.new(constraint).satisfied_by?(version)

  version.to_s
end

The model is measured in three places: with an exact version constraint, with a wide constraint and in a run whose lock file has changed. The last line shows how the rule classifies this observation.

# lock_file.rb — the lock file pins the installed version
require_relative "determinant"
require_relative "dependency"

p installed_version(BASE, "formatter", "~> 1.4.0")
p installed_version(BASE, "formatter", "~> 1.4")
p installed_version(VARIANTS[:dependency], "formatter", "~> 1.4")
p determinant(->(r) { installed_version(r, "formatter", "~> 1.4") })
"1.4.2"
"1.4.2"
"1.5.0"
["dependency", "D+ O- E-", "1.4.2"]

Although the constraint ~> 1.4 is wide, the lock file says 1.4.2, so the installed "1.4.2" stayed; the constraint accepts 1.5.0, but the lock file did not choose it. When the lock file became 1.5.0, the installed version became "1.5.0" too. The fourth line gives the classification: dependency, with the evidence D+ O- E-. The result changed in the dependency variant and did not change in the order and environment variants. The constraint does not pin the version, it only bounds it; the lock file pins. The determinant of this lesson is dependency, and throughout the lesson this word means the contents of the lock file. PG7. The measurement assumes that the version list in the lock file comes from a resolution; that the list was actually produced by a resolver is not part of the model.

Updating the Lock

The lock file does not change by itself. A new version arriving in the registry is not enough to renew the lock file; the lock file is written only when the manager resolves again. This is an explicit operation and is represented in the core by BASE.with: the lock file’s contents are replaced with a new value and the old value stays as it was.

# update.rb — the lock file changes only when it is updated explicitly
require_relative "determinant"
require_relative "dependency"
require_relative "resolution"

registry = %w[1.4.2 1.5.0 1.6.0]
latest = highest_matching(registry, "~> 1.4")
updated = BASE.with(lock: BASE.lock.merge("formatter" => latest))

p installed_version(BASE, "formatter", "~> 1.4")
p installed_version(updated, "formatter", "~> 1.4")
p BASE.lock
p updated.lock
"1.4.2"
"1.6.0"
{"formatter" => "1.4.2", "parser" => "2.0.1"}
{"formatter" => "1.6.0", "parser" => "2.0.1"}

Although 1.6.0 was in the registry, the lock file of BASE stayed at 1.4.2; installed_version gave "1.4.2" in this run. The run with the renewed lock file gave "1.6.0", and only formatter changed: parser stayed at 2.0.1 in the lock file. An update can move only the requested dependency and does not touch the others. This also explains why the dependency is a determinant: what changes the result is not the code but a write to the lock file. The code and the constraint stay the same; when the lock file changes, the result changes. PG8. The update was modeled here for a single dependency and with a pure registry; the joint update of dependencies that rely on each other was not measured.

The Constraints of Two Packages

An application installs more than one package, and each package writes its own constraint for the same dependency. The manager looks for a version that satisfies all constraints together: the intersection of the accepted ranges. If the intersection is empty, there is no resolution. The file below calls accepted with the constraints of two packages.

# intersection.rb — the intersection of the constraints of two packages
def accepted(constraints, versions)
  requirement = Gem::Requirement.new(*constraints)
  versions.select { |v| requirement.satisfied_by?(Gem::Version.new(v)) }
end

registry = %w[1.4.0 1.4.2 1.5.0 1.99 2.0.0]
p accepted(["~> 1.4", ">= 1.5"], registry)
p accepted(["~> 1.4.0", ">= 1.5"], registry)
p accepted(["~> 1.4", "~> 2.0"], registry)

pre_release = Gem::Dependency.new("formatter", "~> 1.4")
p Gem::Requirement.new("~> 1.4").satisfied_by?(Gem::Version.new("1.5.0.pre1"))
p pre_release.match?("formatter", "1.5.0.pre1")
["1.5.0", "1.99"]
[]
[]
true
false

When the first package writes ~> 1.4 and the second writes >= 1.5, 1.5.0 and 1.99 remain in the registry. If the second writes ~> 1.4.0, the intersection empties, because the 1.4.x range and 1.5 onward meet at no version. In the third line two pessimistic constraints separate at the major version and again no version remains. An empty set is not a resolution: the manager does not install here and reports the conflict. Resolution means looking for a version that stays in the intersection of all constraints. This also shows what the lock file does not store: the lock file stores the result of resolution, but whether a resolution exists depends on the intersection of the constraints.

The last two lines separate the pre-release. The constraint object accepts 1.5.0.pre1 by looking only at the numbers. The dependency object does not count a pre-release as a candidate by default. The same constraint gave two results in two objects, so whether a pre-release will be installed depends on the object that asks the question, not on the constraint. PG9. The model of this lesson measures only the objects of the Gem module; the rule by which a real manager counts a pre-release as a candidate was not tested.

Two Machines, One Lock File

The reason for committing the lock file is measured with two machines. The first machine installs today, the second the next day when the registry has gained one more version. An installation without a lock file resolves again on every machine. An installation with a lock file does not resolve and reads the lock file.

# machines.rb — installation without and with a lock file on two machines
require_relative "determinant"
require_relative "dependency"
require_relative "resolution"

today = %w[1.4.2 1.5.0]
tomorrow = today + ["1.6.0"]

p [highest_matching(today, "~> 1.4"), highest_matching(tomorrow, "~> 1.4")]
p [installed_version(BASE, "formatter", "~> 1.4"), installed_version(BASE, "formatter", "~> 1.4")]
["1.5.0", "1.6.0"]
["1.4.2", "1.4.2"]

In the pair without a lock file, the two machines installed two different versions; in the pair with a lock file, both installed the same one. In the second pair the registry was never used: installed_version does not look at the registry, so the next day’s registry contents do not affect the result. The difference between yesterday and today is that the registry changed; the lock file separates this difference from the installation. The lock file provides the consistency, and this holds only if everyone reads the same lock file. If the lock file exists on only one machine, the version the other machine installs says nothing about this measurement. PG10. The model represents the two machines with the same run object; synchronizing the lock file in a real team is a separate job and was not measured here.

Dependency Kind and Constraint

The distinction between runtime and development dependencies was built in the previous lesson through the package specification. What is added here is how the kind relates to the constraint: the kind determines not the installed version but where it will be installed, and both kinds carry a constraint in the same form.

# kind.rb — dependency kind and constraint
formatter = Gem::Dependency.new("formatter", "~> 1.4", :runtime)
examiner = Gem::Dependency.new("examiner", ">= 2", :development)

p [formatter.type, formatter.runtime?]
p [examiner.type, examiner.runtime?]
p formatter.requirement.to_s
p examiner.requirement.to_s
[:runtime, true]
[:development, false]
"~> 1.4"
">= 2"

The type of the development dependency returned :development and runtime? was false; the runtime dependency gave :runtime and true. Both constraint texts were read back as given: "~> 1.4" and ">= 2". The kind of each dependency is read separately, and runtime? is true only for the runtime kind. The text of the constraint is independent of the kind. This distinction explains why the lock file can contain both: the lock file holds the whole resolution, and the kind says which part of that resolution lands on whose machine. PG11. type and runtime? show only what the Gem::Dependency object says; a manager skipping the development dependency during installation lies outside this measurement.

The Bounding Measurement

The rule runs a computation in three variants. If the computation raises an error in any of them, the rule cannot classify. The error of this lesson itself shows this: with the constraint ~> 1.4.0, the lock file in the base (1.4.2) satisfies the constraint, but the lock file of the dependency variant (1.5.0) does not.

# boundary.rb — the rule does not classify when the lock file does not satisfy the constraint
require_relative "determinant"
require_relative "dependency"

begin
  determinant(->(r) { installed_version(r, "formatter", "~> 1.4.0") })
rescue => e
  p e.class
  puts e.message
end
ArgumentError
lock does not satisfy constraint

The rule gave no class; ArgumentError was raised. This error is not the rule’s but the computation’s: the lock file contradicts the constraint. This is the problem seen at installation; the rule does not call it “dependency” because the computation could not produce a result. PG12. This measurement shows only what happens when the lock file and the constraint contradict each other; a real manager may choose to remove the contradiction by resolving the lock file again, which lies outside this model.

Summary

  • A version constraint is not a version but an accepted range: ~> 1.4 keeps the major version fixed, ~> 1.4.0 frees only the patch version, and a range constraint is the intersection of two bounds.
  • Resolution without a lock file depends on the registry’s contents: when 1.6.0 arrives in the registry, the same constraint gives another version.
  • The determinant of this lesson is dependency: the locked version row shows D+ O- E-, and when the lock file becomes 1.5.0, the installed version changes too.
  • The constraint bounds and the lock file pins; this is why the lock file is committed to the repository.
  • If the lock file does not satisfy the constraint, the rule does not classify and raises an error.

Next Step

The lock file now determines the installed version. But a result can change even with the same code and the same lock file. The next lesson measures why the same tests sometimes fail and how the order determines this.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close