Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions e2e/cli/test_lock_latest
Original file line number Diff line number Diff line change
@@ -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
69 changes: 49 additions & 20 deletions src/cli/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -462,31 +467,37 @@ 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()));
}
}
}
}
// 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));
}
}
}
Expand All @@ -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<LockTool> = 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
}
}

Expand Down
Loading