From 7268c2adab05b3f341b7e6617b246a1db76a7f21 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Mon, 16 Feb 2026 14:24:01 +0100 Subject: [PATCH 1/3] fix(core): resolve highest version when multiple versions exist in lock file ResolvedPackages now stores all versions per package name and returns the highest semver version through public API. Fixes incorrect outdated status when both direct and transitive dependency versions coexist in the lock file. --- crates/deps-core/src/lockfile.rs | 158 +++++++++++++++++++++++++++---- 1 file changed, 137 insertions(+), 21 deletions(-) diff --git a/crates/deps-core/src/lockfile.rs b/crates/deps-core/src/lockfile.rs index b71700a0..1ce5ad87 100644 --- a/crates/deps-core/src/lockfile.rs +++ b/crates/deps-core/src/lockfile.rs @@ -145,7 +145,8 @@ pub enum ResolvedSource { /// Collection of resolved packages from a lock file. /// -/// Provides efficient lookup of resolved versions by package name. +/// Supports multiple versions per package name, returning the highest +/// semver version through public API methods. /// /// # Examples /// @@ -168,8 +169,22 @@ pub enum ResolvedSource { /// ``` #[derive(Debug, Default, Clone)] pub struct ResolvedPackages { - /// Map from package name to resolved package info - packages: HashMap, + packages: HashMap>, +} + +/// Returns the package with the highest semver version from a slice. +fn best_package(packages: &[ResolvedPackage]) -> Option<&ResolvedPackage> { + packages.iter().max_by(|a, b| { + match ( + semver::Version::parse(&a.version), + semver::Version::parse(&b.version), + ) { + (Ok(va), Ok(vb)) => va.cmp(&vb), + (Ok(_), Err(_)) => std::cmp::Ordering::Greater, + (Err(_), Ok(_)) => std::cmp::Ordering::Less, + (Err(_), Err(_)) => a.version.cmp(&b.version), + } + }) } impl ResolvedPackages { @@ -180,30 +195,30 @@ impl ResolvedPackages { } } - /// Inserts a resolved package. - /// - /// If a package with the same name already exists, it is replaced. + /// Inserts a resolved package, storing all versions per name. pub fn insert(&mut self, package: ResolvedPackage) { - self.packages.insert(package.name.clone(), package); + self.packages + .entry(package.name.clone()) + .or_default() + .push(package); } - /// Gets a resolved package by name. - /// - /// Returns `None` if the package is not in the lock file. + /// Gets the resolved package with the highest semver version. pub fn get(&self, name: &str) -> Option<&ResolvedPackage> { - self.packages.get(name) + self.packages.get(name).and_then(|v| best_package(v)) } - /// Gets the resolved version string for a package. - /// - /// Returns `None` if the package is not in the lock file. - /// - /// This is a convenience method equivalent to `get(name).map(|p| p.version.as_str())`. + /// Gets the highest resolved version string for a package. pub fn get_version(&self, name: &str) -> Option<&str> { - self.packages.get(name).map(|p| p.version.as_str()) + self.get(name).map(|p| p.version.as_str()) + } + + /// Returns all stored versions for a package. + pub fn get_all(&self, name: &str) -> Option<&[ResolvedPackage]> { + self.packages.get(name).map(|v| v.as_slice()) } - /// Returns the number of resolved packages. + /// Returns the number of unique package names. pub fn len(&self) -> usize { self.packages.len() } @@ -213,14 +228,21 @@ impl ResolvedPackages { self.packages.is_empty() } - /// Returns an iterator over package names and their resolved info. + /// Returns an iterator yielding the best version per unique package name. pub fn iter(&self) -> impl Iterator { - self.packages.iter() + self.packages + .keys() + .filter_map(|name| self.packages.get(name).and_then(|v| best_package(v).map(|p| (name, p)))) } - /// Converts into a HashMap for easier integration. + /// Converts into a HashMap with the best version per package name. pub fn into_map(self) -> HashMap { self.packages + .into_iter() + .filter_map(|(name, versions)| { + best_package(&versions).cloned().map(|p| (name, p)) + }) + .collect() } } @@ -495,8 +517,102 @@ mod tests { dependencies: vec![], }); + // Both versions stored, but len counts unique names assert_eq!(packages.len(), 1); assert_eq!(packages.get_version("serde"), Some("1.0.195")); + // Both versions accessible via get_all + assert_eq!(packages.get_all("serde").unwrap().len(), 2); + } + + #[test] + fn test_resolved_packages_multiple_versions() { + let mut packages = ResolvedPackages::new(); + + packages.insert(ResolvedPackage { + name: "serde".into(), + version: "1.0.195".into(), + source: ResolvedSource::Registry { + url: "test".into(), + checksum: "a".into(), + }, + dependencies: vec![], + }); + + packages.insert(ResolvedPackage { + name: "serde".into(), + version: "0.9.0".into(), + source: ResolvedSource::Registry { + url: "test".into(), + checksum: "b".into(), + }, + dependencies: vec![], + }); + + packages.insert(ResolvedPackage { + name: "serde".into(), + version: "2.0.0-beta.1".into(), + source: ResolvedSource::Registry { + url: "test".into(), + checksum: "c".into(), + }, + dependencies: vec![], + }); + + assert_eq!(packages.len(), 1); + assert_eq!(packages.get_version("serde"), Some("2.0.0-beta.1")); + assert_eq!(packages.get_all("serde").unwrap().len(), 3); + } + + #[test] + fn test_resolved_packages_non_semver_fallback() { + let mut packages = ResolvedPackages::new(); + + packages.insert(ResolvedPackage { + name: "weird".into(), + version: "abc".into(), + source: ResolvedSource::Path { + path: ".".into(), + }, + dependencies: vec![], + }); + + packages.insert(ResolvedPackage { + name: "weird".into(), + version: "xyz".into(), + source: ResolvedSource::Path { + path: ".".into(), + }, + dependencies: vec![], + }); + + // Falls back to string comparison: "xyz" > "abc" + assert_eq!(packages.get_version("weird"), Some("xyz")); + } + + #[test] + fn test_resolved_packages_semver_preferred_over_non_semver() { + let mut packages = ResolvedPackages::new(); + + packages.insert(ResolvedPackage { + name: "mixed".into(), + version: "not-a-version".into(), + source: ResolvedSource::Path { + path: ".".into(), + }, + dependencies: vec![], + }); + + packages.insert(ResolvedPackage { + name: "mixed".into(), + version: "1.0.0".into(), + source: ResolvedSource::Path { + path: ".".into(), + }, + dependencies: vec![], + }); + + // Parseable semver is preferred over non-parseable + assert_eq!(packages.get_version("mixed"), Some("1.0.0")); } #[test] From 5bd4ffd4c93a2baf7c141f231a2fa465c780054d Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Mon, 16 Feb 2026 14:25:42 +0100 Subject: [PATCH 2/3] style: apply rustfmt to lockfile module --- crates/deps-core/src/lockfile.rs | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/crates/deps-core/src/lockfile.rs b/crates/deps-core/src/lockfile.rs index 1ce5ad87..733f8d23 100644 --- a/crates/deps-core/src/lockfile.rs +++ b/crates/deps-core/src/lockfile.rs @@ -230,18 +230,18 @@ impl ResolvedPackages { /// Returns an iterator yielding the best version per unique package name. pub fn iter(&self) -> impl Iterator { - self.packages - .keys() - .filter_map(|name| self.packages.get(name).and_then(|v| best_package(v).map(|p| (name, p)))) + self.packages.keys().filter_map(|name| { + self.packages + .get(name) + .and_then(|v| best_package(v).map(|p| (name, p))) + }) } /// Converts into a HashMap with the best version per package name. pub fn into_map(self) -> HashMap { self.packages .into_iter() - .filter_map(|(name, versions)| { - best_package(&versions).cloned().map(|p| (name, p)) - }) + .filter_map(|(name, versions)| best_package(&versions).cloned().map(|p| (name, p))) .collect() } } @@ -570,18 +570,14 @@ mod tests { packages.insert(ResolvedPackage { name: "weird".into(), version: "abc".into(), - source: ResolvedSource::Path { - path: ".".into(), - }, + source: ResolvedSource::Path { path: ".".into() }, dependencies: vec![], }); packages.insert(ResolvedPackage { name: "weird".into(), version: "xyz".into(), - source: ResolvedSource::Path { - path: ".".into(), - }, + source: ResolvedSource::Path { path: ".".into() }, dependencies: vec![], }); @@ -596,18 +592,14 @@ mod tests { packages.insert(ResolvedPackage { name: "mixed".into(), version: "not-a-version".into(), - source: ResolvedSource::Path { - path: ".".into(), - }, + source: ResolvedSource::Path { path: ".".into() }, dependencies: vec![], }); packages.insert(ResolvedPackage { name: "mixed".into(), version: "1.0.0".into(), - source: ResolvedSource::Path { - path: ".".into(), - }, + source: ResolvedSource::Path { path: ".".into() }, dependencies: vec![], }); From 55b2401f552abfbd9bdee8e858eb46e74dc6db9b Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Mon, 16 Feb 2026 14:33:04 +0100 Subject: [PATCH 3/3] release: prepare v0.6.1 --- CHANGELOG.md | 6 +++++- Cargo.lock | 14 +++++++------- Cargo.toml | 16 ++++++++-------- README.md | 2 +- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dcd72ea..3cb3bc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.1] - 2026-02-16 + ### Added - **deps-bundler benchmarks** — Criterion benchmarks for Gemfile/Gemfile.lock parsing with various file sizes (5-100 deps) @@ -17,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **deps-bundler test coverage increased to 90%+** — Added comprehensive tests for error handling, parser edge cases, registry response parsing +- **Lock file duplicate versions** — ResolvedPackages now stores all versions per package name and returns the highest semver version, fixing incorrect outdated status when both direct and transitive dependency versions coexist ## [0.6.0] - 2026-02-03 @@ -298,7 +301,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - TLS enforced via rustls - cargo-deny configured for vulnerability scanning -[Unreleased]: https://github.com/bug-ops/deps-lsp/compare/v0.6.0...HEAD +[Unreleased]: https://github.com/bug-ops/deps-lsp/compare/v0.6.1...HEAD +[0.6.1]: https://github.com/bug-ops/deps-lsp/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/bug-ops/deps-lsp/compare/v0.5.5...v0.6.0 [0.5.5]: https://github.com/bug-ops/deps-lsp/compare/v0.5.4...v0.5.5 [0.5.4]: https://github.com/bug-ops/deps-lsp/compare/v0.5.3...v0.5.3 diff --git a/Cargo.lock b/Cargo.lock index 10d20917..43448ba3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -409,7 +409,7 @@ dependencies = [ [[package]] name = "deps-bundler" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -430,7 +430,7 @@ dependencies = [ [[package]] name = "deps-cargo" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -452,7 +452,7 @@ dependencies = [ [[package]] name = "deps-core" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "bytes", @@ -475,7 +475,7 @@ dependencies = [ [[package]] name = "deps-go" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -495,7 +495,7 @@ dependencies = [ [[package]] name = "deps-lsp" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -522,7 +522,7 @@ dependencies = [ [[package]] name = "deps-npm" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -541,7 +541,7 @@ dependencies = [ [[package]] name = "deps-pypi" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", diff --git a/Cargo.toml b/Cargo.toml index dd02a83e..bf2674be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/deps-zed"] resolver = "3" [workspace.package] -version = "0.6.0" +version = "0.6.1" edition = "2024" rust-version = "1.89" authors = ["Andrei G"] @@ -15,13 +15,13 @@ repository = "https://github.com/bug-ops/deps-lsp" async-trait = "0.1" criterion = "0.8" dashmap = "6.1" -deps-core = { version = "0.6.0", path = "crates/deps-core" } -deps-cargo = { version = "0.6.0", path = "crates/deps-cargo" } -deps-npm = { version = "0.6.0", path = "crates/deps-npm" } -deps-pypi = { version = "0.6.0", path = "crates/deps-pypi" } -deps-go = { version = "0.6.0", path = "crates/deps-go" } -deps-bundler = { version = "0.6.0", path = "crates/deps-bundler" } -deps-lsp = { version = "0.6.0", path = "crates/deps-lsp" } +deps-core = { version = "0.6.1", path = "crates/deps-core" } +deps-cargo = { version = "0.6.1", path = "crates/deps-cargo" } +deps-npm = { version = "0.6.1", path = "crates/deps-npm" } +deps-pypi = { version = "0.6.1", path = "crates/deps-pypi" } +deps-go = { version = "0.6.1", path = "crates/deps-go" } +deps-bundler = { version = "0.6.1", path = "crates/deps-bundler" } +deps-lsp = { version = "0.6.1", path = "crates/deps-lsp" } futures = "0.3" insta = "1" mockito = "1" diff --git a/README.md b/README.md index e488ed6d..35fc2000 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ deps-lsp is optimized for responsiveness: cargo install deps-lsp ``` -Latest published crate version: `0.6.0`. +Latest published crate version: `0.6.1`. > [!TIP] > Use `cargo binstall deps-lsp` for faster installation without compilation.