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
46 changes: 46 additions & 0 deletions src/backend/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ impl Backend for NPMBackend {
VersionInfo {
version: version.to_string(),
created_at,
prerelease: is_semver_prerelease(version),
..Default::default()
}
})
Expand Down Expand Up @@ -682,6 +683,21 @@ impl NPMBackend {
}
}

/// Returns true if `version` is a semver pre-release.
///
/// npm enforces strict semver (rule 9): any hyphen-introduced identifier after
/// the version core is a pre-release (`1.0.0-rc.1`, `0.42.0-nightly...`,
/// `2.0.0-canary.1`, `3.0.0-foo`). Build metadata (`+...`) is stripped first so
/// stable builds like `1.0.0+sha.abc` are not misclassified.
///
/// Stricter than the generic `VERSION_REGEX` channel-tag list — for npm it
/// catches any pre-release tag the maintainer chooses, not just the well-known
/// names mise happens to recognize.
fn is_semver_prerelease(version: &str) -> bool {
let core_and_pre = version.split_once('+').map_or(version, |(v, _)| v);
core_and_pre.contains('-')
}

/// Returns install-time-only option keys for NPM backend.
pub fn install_time_option_keys() -> Vec<String> {
vec![
Expand Down Expand Up @@ -993,4 +1009,34 @@ mod tests {
);
assert!(!resolved.contains_key("install_env.NPM_CONFIG_REGISTRY"));
}

#[test]
fn test_is_semver_prerelease_flags_hyphen_suffix() {
// Per semver rule 9, any hyphen-introduced identifier is a pre-release.
// Covers GitHub discussion #9503 (-nightly slipping past channel-name regex).
assert!(is_semver_prerelease("0.42.0-nightly.20260429.g6d9911393"));
assert!(is_semver_prerelease("1.0.0-rc.1"));
assert!(is_semver_prerelease("2.0.0-canary"));
assert!(is_semver_prerelease("3.0.0-foo"));
// Maintainer-invented tag mise's regex doesn't know about — still flagged.
assert!(is_semver_prerelease("4.0.0-internal-build-7"));
}

#[test]
fn test_is_semver_prerelease_keeps_stable_versions() {
assert!(!is_semver_prerelease("1.0.0"));
assert!(!is_semver_prerelease("0.40.1"));
assert!(!is_semver_prerelease("v22.6.0"));
// Build metadata alone is not a pre-release.
assert!(!is_semver_prerelease("1.0.0+sha.abc1234"));
}

#[test]
fn test_is_semver_prerelease_strips_build_metadata_first() {
// `+build` after a `-pre` tag must still flag as pre-release.
assert!(is_semver_prerelease("1.0.0-rc.1+build.5"));
// Hyphen only inside build metadata (not legal semver, but be defensive)
// — we treat it as stable since the version core has no pre-release.
assert!(!is_semver_prerelease("1.0.0+build-5"));
}
}
26 changes: 25 additions & 1 deletion src/plugins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ pub fn warn_if_env_plugin_shadows_registry(name: &str, plugin_path: &Path) {

pub static VERSION_REGEX: Lazy<regex::Regex> = Lazy::new(|| {
Regex::new(
r"(?i)(^Available versions:|-src|[-\\.]dev|-latest|-stm|[-\\.]rc|-milestone|-alpha|-beta|[-\\.]pre|-next|-test|([abc])[0-9]+|snapshot|SNAPSHOT|master)"
r"(?i)(^Available versions:|-src|[-\\.]dev|-latest|-stm|[-\\.]rc|-milestone|-alpha|-beta|[-\\.]pre|-next|-test|-nightly|-canary|-experimental|-insider|-edge|([abc])[0-9]+|snapshot|SNAPSHOT|master)"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The newly added pre-release identifiers (nightly, canary, experimental, insider, edge) are currently only matched when preceded by a hyphen. For consistency with other identifiers in this regex (like dev, rc, and pre) which allow both hyphens and dots as separators (e.g., 1.0.0.dev), consider using [-\\.] for these new tags as well. This improves robustness for backends that might use different versioning conventions.

Suggested change
r"(?i)(^Available versions:|-src|[-\\.]dev|-latest|-stm|[-\\.]rc|-milestone|-alpha|-beta|[-\\.]pre|-next|-test|-nightly|-canary|-experimental|-insider|-edge|([abc])[0-9]+|snapshot|SNAPSHOT|master)"
r"(?i)(^Available versions:|-src|[-\\.]dev|-latest|-stm|[-\\.]rc|-milestone|-alpha|-beta|[-\\.]pre|-next|-test|[-\\.]nightly|[-\\.]canary|[-\\.]experimental|[-\\.]insider|[-\\.]edge|([abc])[0-9]+|snapshot|SNAPSHOT|master)"

)
.unwrap()
});
Expand Down Expand Up @@ -448,6 +448,30 @@ mod tests {
"PEP 440 .dev suffix with build number should be filtered"
);

// npm prerelease channels (GitHub discussion #9503).
// Use suffixes that don't accidentally match `([abc])[0-9]+`, so each
// assertion exercises only the channel-tag alternative it names.
assert!(
VERSION_REGEX.is_match("0.42.0-nightly.20260429.g6d9911393"),
"npm -nightly tag should be filtered"
);
assert!(
VERSION_REGEX.is_match("13.0.0-canary"),
"npm -canary tag should be filtered"
);
assert!(
VERSION_REGEX.is_match("18.0.0-experimental.1"),
"npm -experimental tag should be filtered"
);
Comment thread
cursor[bot] marked this conversation as resolved.
assert!(
VERSION_REGEX.is_match("1.99.0-insider"),
"npm -insider tag should be filtered"
);
assert!(
VERSION_REGEX.is_match("1.99.0-edge"),
"npm -edge tag should be filtered"
);

// Stable versions should NOT match
assert!(!VERSION_REGEX.is_match("1.0.0"));
assert!(!VERSION_REGEX.is_match("2026.3.3"));
Expand Down
Loading