Dependency Resolution & Dev Environments - SemVer, SAT vs MVS, Lockfiles, Nix Shells & Devcontainers
SemVer and range styles per ecosystem; search (PubGrub/SAT) vs MVS vs nested copies with a runnable diamond demo; lockfiles across ecosystems and integrity hashes (runnable); hoisting and phantom deps; Nix shells vs devcontainers vs mise/asdf vs Docker; works-on-my-machine diagnosis; interview Q&A.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
What does a caret range like ^1.4.2 allow?
Answer
Versions from 1.4.2 up to, but not including, 2.0.0.
L2
Why is dependency resolution hard in general?
Answer
With arbitrary ranges and conflicts it encodes Boolean satisfiability, which is NP-complete.
L3
What does Go's MVS choose?
Answer
For each module, the highest minimum version required anywhere in the graph.
L4
What is the diamond dependency problem?
Answer
Two dependencies need different versions of a shared library.
L5
What two guarantees does a lockfile give?
Answer
Stability of versions and integrity of bytes.
L6
What is a phantom dependency?
Answer
An import that works only because hoisting placed an undeclared package on the resolution path.
L7
Lockfile vs devcontainer vs Nix?
Answer
The lockfile pins language deps, a devcontainer pins an OS image, Nix pins every tool and library by hash.
Failure modes
A broken patch release reaches production through a range
Without a lockfile an application resolves a new version on the next install.
A phantom dependency breaks after an unrelated removal
Package A imported lodash only because package B's copy was hoisted.
Duplicate singletons cause baffling runtime bugs
Two copies of React or a GraphQL schema break instanceof checks and shared state.
Misconceptions
go.sum is Go's lockfile.
go.sum holds checksums only. Versions come from go.mod through MVS.
A version file like .tool-versions pins everything.
It pins the runtime, not native system libraries.
Regenerating the lockfile is a safe way to fix a conflict.
It pulls in dozens of unrelated upgrades. Update targeted packages.
Interviewer traps
Running npm install in CI.
It can rewrite the lockfile. Use npm ci or the equivalent frozen mode.
Expecting MVS to pick up security fixes automatically.
MVS only moves when someone raises a minimum. Use tooling that proposes bumps.
Design scenario
Same prompt for every reader.
Requirements
Same dependency versions and bytes in CI and locally, same runtime and native libraries, and a setup under one hour.
Failure assumptions
- CI runs npm install, not a frozen install.
- Two packages rely on hoisted dependencies they never declared.
- One service needs libvips.
Constraints
- Keep the existing package manager.
- Do not require everyone to run containers all day unless needed.
Prompt
New hires take two days to get a working dev setup and CI sometimes installs different versions than laptops.
API
Which commands do developers and CI run to install?
Data
Which lockfile and integrity settings do you enforce?
Architecture
Which environment tool pins the runtime and native libraries, and how does CI share it?
Overview
Before anything can be built, two questions need answers: which exact versions of every dependency (resolution) and which exact tools are on the machine running the build (environment). Resolution turns version ranges in manifests into a concrete set of versions, using strategies that range from a full constraint search (SAT-style solvers such as PubGrub) to a deliberately simple rule (Go's Minimal Version Selection). Lockfiles freeze that answer. Environment managers (Nix shells, devcontainers, mise or asdf, Docker) freeze the toolchain around it. "Works on my machine" is almost always a gap in one of those two layers.
Semantic versioning and ranges
SemVer encodes intent in MAJOR.MINOR.PATCH: breaking changes bump MAJOR, compatible features bump MINOR, fixes bump PATCH. Manifests then declare ranges, not versions:
| Ecosystem | Default range style | Meaning of a plain requirement | Notes |
|---|---|---|---|
| npm, pnpm, Yarn | ^1.4.2 (written by npm install) | >=1.4.2 <2.0.0 | For 0.x, caret only allows patch changes within the minor |
| Cargo | 1.4.2 | Same as ^1.4.2 | Also treats 0.x minors as breaking |
| Go modules | v1.4.2 in go.mod | A minimum; major versions 2+ live at different import paths | Resolution by MVS, no ranges |
| Python (pip, uv, Poetry) | >=1.4,<2 or ~=1.4 (PEP 440) | Explicit ranges | Poetry also supports caret syntax |
| Bazel Bzlmod | bazel_dep(version = "1.4.2") | A minimum, resolved by MVS | multiple_version_override allows exceptions |
SemVer is a promise made by humans, so ranges only work as well as maintainers' discipline. Lockfiles exist because that promise is sometimes broken.
How do you pick one version per dependency?
Prefer
Resolve once, then lock versions and hashes
A resolver answers once, and the lockfile freezes the answer and its bytes for everyone.
- Frozen installs fail instead of rewriting the lock.
- Integrity hashes refuse swapped tarballs.
- Targeted updates keep upgrades reviewable.
Alternative
Resolve ranges fresh on every install
Ranges let new releases flow in without review.
- A broken patch release reaches production.
- CI and laptops resolve different trees.
- Nobody notices a tampered tarball.
From manifest ranges to files on disk
Diagram 1 condensed. The diamond demo shows step 4 under three strategies.
- 1
Read manifest ranges
Every dependency declares a range or a minimum. - 2
Use the lockfile if it still fits
Install exactly the locked versions. - 3
Otherwise resolve and write the lock
Search, MVS or nesting, then record versions and hashes. - 4
Verify integrity, then lay out
A hash mismatch refuses the install.
Three resolution strategies
| Strategy | How it chooses | Pros | Cons | Used by |
|---|---|---|---|---|
| Newest compatible, one copy each (search) | Find a version for every package that satisfies all ranges, preferring newest; backtrack on conflict | One copy of each library; picks up fixes | General problem is NP-complete; conflicts can be hard to explain | pip (resolvelib), uv and Dart pub (PubGrub), Poetry, Bundler |
| Minimal Version Selection | For each module, take the highest of the minimum versions anyone requires; no search | Fast, predictable, no lockfile needed for version choice | Never upgrades unless someone raises a minimum | Go modules, Bazel Bzlmod |
| Nested or duplicated copies | Each dependent may get its own copy of a conflicting dependency | Conflicts rarely block installs | Duplicate code, bigger installs, two copies of a type or singleton | npm and pnpm (nested), Cargo (one copy per semver-compatible range, duplicates across incompatible majors) |
Russ Cox's Version SAT essay shows why the first family is hard: with arbitrary constraints, picking compatible versions encodes Boolean satisfiability. His later MVS design avoids the hard case by allowing only minimum requirements, which makes resolution a simple graph walk. PubGrub, designed by Natalie Weizenbaum for Dart's pub, applies conflict-driven clause learning ideas from SAT solvers: when it hits a conflict it records an incompatibility, which both prunes the search and produces a human-readable explanation of why no solution exists.
Decisions
- 1
Step 1: read root manifest ranges
- nextStep 2: lockfile present and still satisfies the manifest?
- ?
Step 2: lockfile present and still satisfies the manifest?
- nextStep 3a: install exactly the locked versions and verify integrity hashes
- nextStep 3b: query registry metadata for candidate versions
- 3
Step 3a: install exactly the locked versions and verify integrity hashes
- nextStep 7: integrity hash matches the downloaded artifact?
- 4
Step 3b: query registry metadata for candidate versions
- nextStep 4: resolve with the ecosystem strategy (search, MVS or nesting)
- 5
Step 4: resolve with the ecosystem strategy (search, MVS or nesting)
- nextStep 5: a consistent set exists?
- ?
Step 5: a consistent set exists?
- nextFailure path: report the conflict chain, user loosens a range or overrides
- nextStep 6: write the lockfile with versions and hashes
- 7
Failure path: report the conflict chain, user loosens a range or overrides
- 8
Step 6: write the lockfile with versions and hashes
- nextStep 3a: install exactly the locked versions and verify integrity hashes
- ?
Step 7: integrity hash matches the downloaded artifact?
- nextFailure path: refuse to install, possible tampering or registry change
- nextStep 8: lay out dependencies on disk (nested, hoisted, symlinked or store paths)
- 10
Failure path: refuse to install, possible tampering or registry change
- 11
Step 8: lay out dependencies on disk (nested, hoisted, symlinked or store paths)
Lesson map
Dependency Resolution & Dev Environments - SemVer, SAT vs MVS, Lockfiles, Nix Shells & Devcontainers
SemVer and range styles per ecosystem; search (PubGrub/SAT) vs MVS vs nested copies with a runnable diamond demo; lockfiles across ecosystems and integrity hashes (runnable); hoisting and phantom deps; Nix shells vs devcontainers vs mise/asdf vs Docker; works-on-my-machine diagnosis; interview Q&A.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["Step 1: read root manifest ranges"] b["Step 2: lockfile present and still satisfies the manifest?"] l["Step 3a: install exactly the locked versions and verify integrity hashes"] c["Step 3b: query registry metadata for candidate versions"] d["Step 4: resolve with the ecosystem strategy (search, MVS or nesting)"] e["Step 5: a consistent set exists?"] x["Failure path: report the conflict chain, user loosens a range or overrides"] f["Step 6: write the lockfile with versions and hashes"] g["Step 7: integrity hash matches the downloaded artifact?"] y["Failure path: refuse to install, possible tampering or registry change"] h["Step 8: lay out dependencies on disk (nested, hoisted, symlinked or store paths)"] a -->|continues| b b -->|continues| l b -->|continues| c c -->|continues| d d -->|continues| e e -->|continues| x e -->|continues| f f -->|continues| l l -->|continues| g g -->|continues| y g -->|continues| h
Lockfiles: freezing the answer
| What it records | Who reads it | Notes | |
|---|---|---|---|
package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock | Every resolved version, source and integrity hash | npm ci, pnpm install --frozen-lockfile, yarn install --immutable | CI should fail if the lockfile is out of date rather than rewrite it |
Cargo.lock | Exact versions and checksums | Cargo | Takes priority over newer compatible releases until cargo update |
uv.lock, poetry.lock | Exact versions and hashes, often for several platforms | uv, Poetry | Cross-platform locks resolve once for many targets |
go.sum | Checksums only | Go toolchain | Not a lockfile: versions come from go.mod via MVS; go.sum verifies content |
MODULE.bazel.lock | Resolution results and extension outputs | Bazel | Speeds up and stabilizes Bzlmod resolution |
flake.lock | Exact revisions and hashes of flake inputs | Nix | Pins the whole package set, not just one language |
// Why lockfiles exist: the same manifest resolves differently on different days,
// and an integrity hash catches a tampered or swapped tarball.
function digest(s: string): string {
// FNV-1a for the demo; real lockfiles store sha512 (npm "integrity") or sha256 (Cargo "checksum").
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
return "fnv1a-" + h.toString(16).padStart(8, "0");
}
// Registry state on two dates for a made-up package: 4.18.0 (with a regression) was published in between.
const published: Record<string, string[]> = {
"2026-09-01": ["4.17.0", "4.17.1"],
"2026-10-07": ["4.17.0", "4.17.1", "4.18.0"],
};
const tarball = (ver: string) => `acme-http@${ver} contents`;
const satisfiesCaret = (ver: string, base: string) => {
const [a, b, c] = ver.split(".").map(Number);
const [x, y, z] = base.split(".").map(Number);
return a === x && (b > y || (b === y && c >= z));
};
const newest = (vs: string[]) => vs.sort((p, q) => (p.localeCompare(q, undefined, { numeric: true }))).at(-1)!;
const manifest = { "acme-http": "^4.17.0" };
const base = manifest["acme-http"].slice(1);
// Without a lockfile every install re-resolves the range.
for (const day of Object.keys(published)) {
const ver = newest(published[day].filter((x) => satisfiesCaret(x, base)));
console.log(`no lockfile, install on ${day}: acme-http@${ver}`);
}
// With a lockfile we record the exact version and an integrity hash once.
const lock = { "acme-http": { version: "4.17.1", integrity: digest(tarball("4.17.1")) } };
console.log(`lockfile pins acme-http@${lock["acme-http"].version} ${lock["acme-http"].integrity}`);
// A later install verifies bytes against the lockfile.
function install(served: string) {
const ok = digest(served) === lock["acme-http"].integrity;
console.log(` install with lockfile: ${ok ? "integrity OK" : "INTEGRITY MISMATCH, refusing to install"}`);
}
install(tarball("4.17.1"));
install("acme-http@4.17.1 contents + injected postinstall");Output:
no lockfile, install on 2026-09-01: acme-http@4.17.1
no lockfile, install on 2026-10-07: acme-http@4.18.0
lockfile pins acme-http@4.17.1 fnv1a-e138ef85
install with lockfile: integrity OK
install with lockfile: INTEGRITY MISMATCH, refusing to installExpectedno lockfile, install on 2026-09-01: acme-http@4.17.1 no lockfile, install on 2026-10-07: acme-http@4.18.0 lockfile pins acme-http@4.17.1 fnv1a-e138ef85 install with lockfile: integrity OK install with lockfile: INTEGRITY MISMATCH, refusing to install
Press Run. Snippets must be self-contained — no network, files, or native modules.
A lockfile gives you two different guarantees: stability (the same versions next month) and integrity (the same bytes as when the lock was written). Integrity hashes are also a supply-chain control: a swapped tarball fails installation instead of running.
On-disk layout: hoisting and phantom dependencies
| Layout | How it works | Upside | Downside |
|---|---|---|---|
| Nested | Each package gets its own node_modules | Exact, conflict-free | Deep trees, duplication (old npm) |
| Hoisted (flat) | Dependencies lifted to the top node_modules when possible | Less duplication, fewer path issues | Code can import packages it never declared (phantom dependencies); which version wins depends on hoisting order |
| Symlinked store | Content-addressable store with hard links; each package sees only its declared deps through symlinks | Strict, disk-efficient | Tools assuming a flat layout may break (pnpm offers nodeLinker: hoisted for those) |
Resolver-driven, no node_modules | Runtime resolves imports from a map (Yarn Plug'n'Play) | Fast installs, strict | Requires tool compatibility |
| Store paths | Each dependency at an immutable hashed path (Nix) | Exact and side-by-side versions | Ecosystems must be packaged for Nix |
Phantom dependencies are a monorepo hazard: package A imports lodash without declaring it, which works only because package B's dependency got hoisted. Remove B and A breaks, and affected detection never saw the edge.
Dev environments: freezing the toolchain
| Nix shells and flakes | Devcontainers | mise / asdf | Docker (build or dev image) | |
|---|---|---|---|---|
| What it pins | Every tool and library by store path | A full container image plus editor config | Versions of language runtimes and CLIs per directory | An OS image with installed tools |
| Isolation | Separate store paths on the host; no container | Container | None; tools installed on the host side by side | Container |
| Reproducibility | High, with flake.lock | As good as the image (digest pinned or rebuilt) | Versions pinned; builds of those versions come from upstream | As good as the image |
| Host OS | Linux and macOS | Anything that runs containers | Linux, macOS (Windows partly) | Anything that runs containers |
| Startup and cost | Fast once cached; learning curve | Slower start, heavier; great onboarding in editors and Codespaces | Very light | Medium |
| Shared with CI? | Yes, same flake | Yes, same image | Yes, same version file | Yes, same image |
These overlap more than they compete: a common pattern is mise or a Nix shell for local tools and the same pinned versions in CI, or a devcontainer whose image is built from a Nix or Dockerfile definition. What they share is the idea of a checked-in, versioned description of the environment. Where they differ is how much they pin (just language runtimes, or everything down to the C library) and whether they isolate with containers or with hashed paths.
Decision chart: which environment tool?
Decisions
- 1
A
- nextmise or asdf version files plus the lockfile
- nextStep 2: is the team comfortable running containers for daily work?
- 2
mise or asdf version files plus the lockfile
- ?
Step 2: is the team comfortable running containers for daily work?
- nextStep 3: do you want editor and cloud workspace integration?
- nextStep 4: willing to learn a new language for exact pinning?
- ?
Step 3: do you want editor and cloud workspace integration?
- nextDevcontainer with an image pinned by digest
- nextDocker dev image shared with CI
- 5
Devcontainer with an image pinned by digest
- 6
Docker dev image shared with CI
- ?
Step 4: willing to learn a new language for exact pinning?
- nextNix flake dev shell with flake.lock
- nextFailure path: drift returns, document versions and add CI version checks
- 8
Nix flake dev shell with flake.lock
- 9
Failure path: drift returns, document versions and add CI version checks
"Works on my machine": a diagnosis table
| Symptom | Layer at fault | Fix |
|---|---|---|
| Works locally, CI gets a different dependency version | Resolution: no lockfile or CI rewrote it | Commit the lockfile; use frozen or immutable install in CI |
Works after npm install on one laptop only | Hoisting and phantom dependencies | Strict layout (pnpm), lint for undeclared imports |
| Different compiler or Node version | Environment | Pin with mise, Nix, devcontainer or engines checks |
Missing system library (libssl, libvips) | Environment below the language | Nix or a container image |
| Env var set on one machine only | Hidden input | Declare env in task config; .env.example; strict env mode |
| Passes locally, wrong cache hit in CI | Cache key | See the caching page: declare the input |
Runnable: the diamond under three strategies
app needs web ^1.0 and auth ^2.0, and both depend on a shared log library with different ranges. Watch how each strategy answers.
"""The diamond problem under three resolution strategies.
app needs web ^1.0 and auth ^2.0 ; both depend on the shared library 'log'.
- newest-compatible, one copy per package (pip/uv, Poetry, Dart pub style): search with
backtracking (a tiny SAT-like search) for ONE version of each package
- Minimal Version Selection (Go modules, Bazel Bzlmod): take the max of declared minimums,
no search, never newer than someone asked for
- nested duplicates (npm/pnpm style; Cargo does this too, but only across
semver-incompatible versions such as log 1.x vs log 2.x): each dependent may get its own copy
"""
from itertools import product
def v(s): return tuple(int(x) for x in s.split("."))
# registry: package -> version -> {dependency: (min_inclusive, max_exclusive)}
registry = {
"web": {"1.0.0": {"log": ("1.2.0", "2.0.0")}, "1.4.0": {"log": ("1.5.0", "2.0.0")}},
"auth": {"2.0.0": {"log": ("1.0.0", "2.0.0")}, "2.3.0": {"log": ("2.0.0", "3.0.0")}},
"log": {"1.2.0": {}, "1.5.0": {}, "1.9.0": {}, "2.1.0": {}},
}
root = {"web": ("1.0.0", "2.0.0"), "auth": ("2.0.0", "3.0.0")}
def ok(ver, rng): return v(rng[0]) <= v(ver) < v(rng[1])
def newest_single():
# Try candidate combinations, newest first; first consistent one wins (backtracking).
names = ["web", "auth", "log"]
cands = {n: sorted(registry[n], key=v, reverse=True) for n in names}
tried = 0
for combo in product(*(cands[n] for n in names)):
tried += 1
pick = dict(zip(names, combo))
reqs = list(root.items()) + [(d, r) for n in ("web", "auth") for d, r in registry[n][pick[n]].items()]
if all(ok(pick[d], r) for d, r in reqs):
return pick, tried
return None, tried
def mvs():
# Each module lists MINIMUM versions; build list = max of the minimums, no upper bounds.
pick = {"web": root["web"][0], "auth": root["auth"][0]}
mins = [registry[n][pick[n]]["log"][0] for n in ("web", "auth")]
pick["log"] = max(mins, key=v)
return pick
def nested():
# Newest of each, and every dependent resolves its own 'log' independently.
pick = {n: max((x for x in registry[n] if ok(x, root[n])), key=v) for n in root}
copies = {f"log (for {n})": max((x for x in registry["log"] if ok(x, registry[n][pick[n]]["log"])), key=v) for n in pick}
return pick, copies
pick, tried = newest_single()
print("newest-compatible single copy:", pick, f"(after {tried} candidate combos)")
print("minimal version selection: ", mvs())
p, copies = nested()
print("nested duplicates: ", p, copies)
print("note: auth 2.3.0 wants log 2.x, web wants log 1.x; one-copy resolvers must backtrack to auth 2.0.0")Output:
newest-compatible single copy: {'web': '1.4.0', 'auth': '2.0.0', 'log': '1.9.0'} (after 6 candidate combos)
minimal version selection: {'web': '1.0.0', 'auth': '2.0.0', 'log': '1.2.0'}
nested duplicates: {'web': '1.4.0', 'auth': '2.3.0'} {'log (for web)': '1.9.0', 'log (for auth)': '2.1.0'}
note: auth 2.3.0 wants log 2.x, web wants log 1.x; one-copy resolvers must backtrack to auth 2.0.0The search finds the only single-copy combination by giving up the newest auth. MVS stays at the oldest versions anyone asked for. Nesting gets the newest of everything at the price of two log copies, which is harmless for a pure function library and a real bug for a library holding global state or shared types.
What happens if you choose otherwise
- No lockfile for an application: builds drift silently; a broken patch release reaches production through a range.
- Lockfile for a published library consumed by others: it only affects your own CI, since consumers resolve their own trees. Keep it for reproducible CI but test against fresh resolutions too.
- MVS where you expected newest: security fixes are not picked up until someone bumps a minimum. Use tooling that proposes bumps.
- Containers only for dev environments: strong isolation but slower feedback; native tools pinned by mise or Nix are lighter.
Pitfalls
- Regenerating the whole lockfile to fix one conflict pulls in dozens of unrelated upgrades. Update targeted packages.
npm installin CI can rewrite the lockfile; usenpm cior the equivalent frozen mode.- Duplicated copies of libraries with singletons (React, GraphQL schemas, class instances checked with
instanceof) cause baffling runtime bugs. .tool-versionsormise.tomlpins the runtime, not native system libraries.
Interview Q&A
Why is dependency resolution hard?
Answer
With arbitrary version ranges and conflicts, choosing one version per package that satisfies every constraint is equivalent to Boolean satisfiability, which is NP-complete. Real solvers use heuristics and conflict learning (PubGrub) or restrict the problem (MVS).
What does Go's Minimal Version Selection do?
Answer
Each module states minimum versions; the build uses, for each module, the highest minimum required anywhere in the graph. No search is needed and the result is reproducible without a separate lockfile, while go.sum verifies content.
What is the diamond dependency problem?
Answer
Two dependencies need different versions of a shared library. Ecosystems either pick one compatible version, fail if none exists, or install both copies with the risk of duplicated state or types.
What is a phantom dependency?
Answer
A package that code imports without declaring it, which works only because a hoisted install happened to place it on the resolution path.
Lockfile vs devcontainer vs Nix: which solves "works on my machine"?
Answer
Each covers a different layer: the lockfile pins language dependencies, a devcontainer pins a whole OS image, and Nix pins every tool and library by hash without a container. Most teams combine a lockfile with one environment tool.
Should a library commit its lockfile?
Answer
Usually yes for reproducible CI, knowing consumers ignore it. Add a CI job that tests against freshly resolved dependencies to catch breakage your users will see.
What two guarantees does a lockfile give?
Answer
Stability, meaning the same versions next month, and integrity, meaning the same bytes as when the lock was written. A swapped tarball fails installation instead of running.
Why is go.sum not a lockfile?
Answer
It stores checksums only. Versions come from go.mod through MVS, and go.sum verifies the downloaded content.
Why do duplicate copies of React or a GraphQL schema cause bugs?
Answer
Libraries with singletons, shared types or instanceof checks see two separate copies, so state and type identity split.
Check yourself
Take the last 'works on my machine' bug you saw. Place it in a row of the diagnosis table, name the layer at fault, and write the one-line fix that would have prevented it.
Elsewhere in the library
These pages stay as they are. This lesson only points at them: Go — Toolchain, Modules & Workspaces (Go 1.27), Go — Packages, Testing, Fuzzing & Benchmarks, Supply Chain Security — Signing, SBOMs & OIDC Federation, TypeScript — Modules, Emit, Strictness, Declaration Files & Tooling, CI Performance — Caching, Parallelism & Flaky Jobs.