diff --git a/CLAUDE.md b/CLAUDE.md index d342ebc28c..01a5efdd02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,24 @@ If the replacement has been available for a long time, the CLI warning can start ### Backend System When implementing new tool backends, follow the pattern in `src/backend/mod.rs`. Each backend must implement the `Backend` trait with methods for listing versions, installing tools, and managing tool metadata. +### DO NOT ASSUME SEMVER +**Do not assume tool versions follow semver or any other orderable scheme.** mise manages hundreds of tools with wildly different versioning conventions: + +- Date-based: `2024.01.15`, `20241015` +- Pre-release / ref / tag versions: `tip`, `HEAD`, `nightly`, `edge`, `canary`, `ref:main`, `tag:v1`, `sub-X.Y:...` +- Non-numeric tags: Python `3.12.0a1`, Ruby `3.2.0-preview1`, Go `1.22rc1`, Node `lts/hydrogen`, `lts-iron` +- Tool-specific meanings of `latest` (e.g. some exclude pre-releases, some don't) + +**Rules:** +1. Do not call `versions::Versioning::new(...)` (or any other semver comparator) at a new call site to pick the "newest" version, "resolve latest", or sort a version list. That crate silently returns `None` / arbitrary ordering for non-semver strings, which means wrong versions get chosen for many tools. +2. To resolve a version request (`latest`, a prefix, a channel name), delegate to the backend via `Backend::latest_version`, `Backend::latest_installed_version`, `Backend::list_versions_matching`, or `ToolRequest::resolve` — the backend knows what "latest" means for its tool. +3. To list installed versions in a meaningful order, use `Backend::list_installed_versions_matching` or the toolset's resolved versions. Do not reorder them yourself. +4. Lockfile version strings must be treated as opaque — compare with `==`, never with a version ordering. Never write a non-concrete string (`latest`, `lts/*`, a prefix) into the lockfile; resolve first. + +A few existing call sites (e.g. runtime symlinks) do use `Versioning` ordering today, but that's legacy behavior and arguably also wrong — do not point at them to justify new semver assumptions. + +If you think you need to pick "the newest installed version" at a new call site, stop and ask — that call almost always belongs on the backend, not inline. + ### Plugin Development - Core tools are implemented in `src/plugins/core/` - External plugins use ASDF or vfox compatibility layers diff --git a/e2e/cli/test_lock_latest b/e2e/cli/test_lock_latest new file mode 100644 index 0000000000..dc22650b87 --- /dev/null +++ b/e2e/cli/test_lock_latest @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Tests for `mise lock` with `tool@latest` and poisoned "latest" lockfiles. +# Regression coverage for bugs where: +# - `mise lock tool@latest` wrote the literal string "latest" to the lockfile +# instead of resolving it to a concrete installed version. +# - `mise lock tool` (or `mise lock`) with a lockfile that already held +# `version = "latest"` produced a duplicate entry alongside the concrete +# installed version. + +export MISE_LOCKFILE=1 + +cat <<'EOF' >mise.toml +[tools] +tiny = "latest" +EOF +mise uninstall tiny --all 2>/dev/null || true +mise install tiny@3.0.0 +mise install tiny@3.1.0 + +# ------------------------------------------------------------------ +# `mise lock tool@latest` must resolve to the newest installed +# concrete version, never write the literal "latest". +# ------------------------------------------------------------------ +cat <<'EOF' >mise.lock +# @generated +[[tools.tiny]] +version = "3.0.0" +EOF +mise lock tiny@latest --platform linux-x64 +assert_contains "cat mise.lock" 'version = "3.1.0"' +assert_not_contains "cat mise.lock" 'version = "latest"' +assert_not_contains "cat mise.lock" 'version = "3.0.0"' + +# ------------------------------------------------------------------ +# A poisoned lockfile containing `version = "latest"` must be cleaned up +# on a single `mise lock` run and replaced with the concrete version, +# never producing a duplicate entry. +# ------------------------------------------------------------------ +cat <<'EOF' >mise.lock +# @generated +[[tools.tiny]] +version = "latest" +EOF +mise lock tiny --platform linux-x64 +assert_contains "cat mise.lock" 'version = "3.1.0"' +assert_not_contains "cat mise.lock" 'version = "latest"' +# There should be exactly one [[tools.tiny]] entry. +assert "[ \"$(grep -c '^\[\[tools.tiny\]\]' mise.lock)\" = \"1\" ]" + +# ------------------------------------------------------------------ +# Refresh semantics: `mise lock tool` with a valid lockfile must +# preserve the current version (not introduce a newer one via the +# "latest" fallback). +# ------------------------------------------------------------------ +cat <<'EOF' >mise.lock +# @generated +[[tools.tiny]] +version = "3.0.0" +EOF +mise lock tiny --platform linux-x64 +assert_contains "cat mise.lock" 'version = "3.0.0"' +assert_not_contains "cat mise.lock" 'version = "3.1.0"' +assert "[ \"$(grep -c '^\[\[tools.tiny\]\]' mise.lock)\" = \"1\" ]" + +rm -f mise.toml mise.lock +unset MISE_LOCKFILE diff --git a/src/cli/lock.rs b/src/cli/lock.rs index 1805bc26ba..3566847129 100644 --- a/src/cli/lock.rs +++ b/src/cli/lock.rs @@ -445,6 +445,11 @@ impl Lock { continue; } } + // Skip unresolved symbolic versions (e.g., a lockfile poisoned with "latest" + // as the version). Pass 2's fallback will resolve these to a concrete version. + if tv.version == "latest" { + continue; + } let key = (backend.ba().short.clone(), tv.version.clone()); if seen.insert(key) { all_tools.push((backend.ba().as_ref().clone(), tv)); @@ -462,9 +467,13 @@ impl Lock { for request in requests { if let Ok(backend) = ba.backend() { // Check if the resolved toolset has a matching version + let mut matched_resolved = false; if let Some(resolved_tv) = ts.versions.get(ba.as_ref()) { for tv in &resolved_tv.versions { - if tv.request.version() == request.version() { + if tv.request.version() == request.version() + && tv.version != "latest" + { + matched_resolved = true; let key = (ba.short.clone(), tv.version.clone()); if seen.insert(key) { all_tools.push((ba.as_ref().clone(), tv.clone())); @@ -472,21 +481,23 @@ impl Lock { } } } - // For "latest" or prefix requests not yet matched, find the - // best installed version (handles overridden tools) - if request.version() == "latest" { - let installed = backend.list_installed_versions(); - if let Some(latest_version) = installed.iter().max_by(|a, b| { - versions::Versioning::new(a).cmp(&versions::Versioning::new(b)) - }) { - let key = (ba.short.clone(), latest_version.clone()); - if seen.insert(key) { - let tv = crate::toolset::ToolVersion::new( - request.clone(), - latest_version.clone(), - ); - all_tools.push((ba.as_ref().clone(), tv)); - } + // For "latest" requests where nothing was resolved (e.g., tool was + // overridden by a higher-priority config, or the lockfile holds a + // bogus "latest" literal), ask the backend to resolve `latest` + // against installed versions. Deliberately not sorting version + // strings ourselves — each backend knows its own versioning scheme. + if !matched_resolved + && request.version() == "latest" + && let Ok(Some(latest_version)) = + backend.latest_installed_version(Some("latest".to_string())) + { + let key = (ba.short.clone(), latest_version.clone()); + if seen.insert(key) { + let tv = crate::toolset::ToolVersion::new( + request.clone(), + latest_version, + ); + all_tools.push((ba.as_ref().clone(), tv)); } } } @@ -507,17 +518,35 @@ impl Lock { (t.ba.short.clone(), version) }) .collect(); - all_tools + // For `tool@latest`, we want upgrade semantics: resolve "latest" to an + // installed concrete version and lock that. Writing the literal "latest" + // string to the lockfile would be a bug. Use the backend's own resolver so + // we don't impose a semver ordering on tools that don't follow semver. + let mut tools: Vec = all_tools .into_iter() .filter(|(ba, _)| specified_versions.contains_key(&ba.short)) .map(|(ba, mut tv)| { - // If a specific version was requested, override the resolved version if let Some(Some(version)) = specified_versions.get(&ba.short) { - tv.version.clone_from(version); + if version == "latest" { + if let Some(latest_version) = crate::backend::get(&ba) + .and_then(|b| { + b.latest_installed_version(Some("latest".to_string())).ok() + }) + .flatten() + { + tv.version = latest_version; + } + } else { + tv.version.clone_from(version); + } } (ba, tv) }) - .collect() + .collect(); + // Deduplicate after potential "latest" -> concrete-version resolution. + let mut seen_after: BTreeSet<(String, String)> = BTreeSet::new(); + tools.retain(|(ba, tv)| seen_after.insert((ba.short.clone(), tv.version.clone()))); + tools } }