From b8629881e57741288e9dc19fdd8be3eaf590a194 Mon Sep 17 00:00:00 2001 From: owa Date: Thu, 1 Oct 2026 00:56:20 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20npm=E3=81=AE=E9=9D=9E=E6=8E=A8=E5=A5=A8?= =?UTF-8?q?=E7=89=88=E3=82=92=E6=9B=B4=E6=96=B0=E5=80=99=E8=A3=9C=E3=81=8B?= =?UTF-8?q?=E3=82=89=E9=99=A4=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各版のメタデータを読み捨てていたため、非推奨の版も更新先に選ばれていた。 deprecated の値だけを保持して npm と同じ真偽値で除外し、既存の latest 判定と公開日時の処理を維持する。 JSONから更新判定までの回帰テストと英日ドキュメントを追加する。 --- docs/ecosystems.ja.md | 2 + docs/ecosystems.md | 2 + src/registry/npm.rs | 265 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 236 insertions(+), 33 deletions(-) diff --git a/docs/ecosystems.ja.md b/docs/ecosystems.ja.md index 63d312d..49cb2a2 100644 --- a/docs/ecosystems.ja.md +++ b/docs/ecosystems.ja.md @@ -54,6 +54,8 @@ tauri: 2.9.0 → 2.10.1 `package.json` で更新するのは `dependencies`、`devDependencies`、`peerDependencies`、`optionalDependencies` です。`overrides` などのセクションは変更しません。 +npm レジストリのメタデータで、空でない `deprecated` メッセージが付いた版は更新候補から外します。プレリリースや `latest` タグが指す版も対象です。空のメッセージは非推奨指定の解除として扱います。文字列以外の値は npm と同じ JavaScript の真偽値で判定し、`null`・`false`・数値のゼロは非推奨とみなしません。候補が残らない場合は、その依存をスキップして現在のバージョンを維持します。 レジストリのフィルタですべての版が除外された場合、既存のスキップ理由 `fetch failed: no versions available` を表示します。このスキップは終了コードに影響しません。 + チルダの従来形式 `~>1.2.3`(node-semver 互換)も受け付け、更新後も `~>` を保持します。 npm の `=1.2` や `=1` のように、セグメントの一部だけを書いた比較(partial comparator)は固定指定ではありません。node-semver では `=1.2` が「1.2.x のどれでもよい」(`>=1.2.0 <1.3.0` と同じ)という意味になるためです。depup は `=` 演算子と、書かれているセグメント数を保って更新します(`=1.2` → `=2.3`、`=1` → `=2`)。 diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 3c700bb..1e655d0 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -54,6 +54,8 @@ For `package.json` (including Bun catalogs) and `composer.json`, JSON-escaped se In `package.json`, depup updates `dependencies`, `devDependencies`, `peerDependencies`, and `optionalDependencies`; `overrides` and other sections are left untouched. +Versions with a non-empty `deprecated` message in npm registry metadata are excluded from update candidates, including prereleases and versions referenced by `latest`. An empty message clears deprecation. For non-string values, the flag follows npm's JavaScript truthiness (`null`, `false`, and zero are not deprecated). If no eligible version remains, depup skips the dependency and leaves its current version unchanged. If registry filtering removes every version, the existing skip reason is `fetch failed: no versions available`; this skip does not change the exit code. + depup accepts the node-semver-compatible legacy tilde spelling `~>1.2.3` and preserves `~>` when updating. For npm partial comparators, `=1.2` and `=1` follow node-semver's partial-version rules instead of being treated as pinned exact versions: in node-semver, `=1.2` means any 1.2.x (`>=1.2.0 <1.3.0`). depup keeps the `=` operator and updates only the visible segment shape (`=1.2` → `=2.3`, `=1` → `=2`). diff --git a/src/registry/npm.rs b/src/registry/npm.rs index d52640c..7644170 100644 --- a/src/registry/npm.rs +++ b/src/registry/npm.rs @@ -10,7 +10,7 @@ use crate::update::{VersionInfo, compare_semver_versions, is_prerelease_version} use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde::Deserialize; -use serde::de::IgnoredAny; +use serde_json::Value; use std::collections::HashMap; /// npm レジストリのベース URL @@ -31,9 +31,66 @@ struct NpmPackageResponse { time: HashMap, /// 利用可能なバージョン /// - /// キー (バージョン文字列) のみ使用する。値は packument 全体のメタデータで - /// 巨大になりうるため、`IgnoredAny` で読み捨ててメモリ/CPU を節約する。 - versions: HashMap, + /// 非推奨の判定に必要な値だけを保持し、dependencies / dist 等は読み捨てる。 + versions: HashMap, +} + +/// npm の各版のメタデータ。未知フィールドは serde が読み捨てる。 +#[derive(Debug, Deserialize)] +struct NpmVersionMetadata { + /// 作者由来の値なので、型が違っても取得全体を失敗させない。 + #[serde(default)] + deprecated: Option, +} + +impl NpmVersionMetadata { + /// npm-pick-manifest と同じ JS の真偽値で非推奨を判定する。 + /// 空文字列は npm deprecate による指定解除を意味する。 + fn is_deprecated(&self) -> bool { + match &self.deprecated { + None | Some(Value::Null) => false, + Some(Value::Bool(flag)) => *flag, + Some(Value::String(message)) => !message.is_empty(), + Some(Value::Number(number)) => number.as_f64().is_some_and(|n| n != 0.0), + Some(Value::Array(_) | Value::Object(_)) => true, + } + } +} + +impl NpmPackageResponse { + /// 更新候補を公開時刻付きのバージョン一覧へ変換する。 + fn into_versions(self) -> Vec { + // dist-tags から公式の "latest" バージョンを取得 + // npm が安定版とみなすバージョン + let latest_version = self.dist_tags.get("latest").map(|s| s.as_str()); + + let mut versions = Vec::new(); + + for (version, metadata) in self.versions { + if metadata.is_deprecated() { + continue; + } + + // dist-tags.latest より新しい「安定版に見える」バージョンをスキップ + // (検出可能なプレリリース (canary/beta 等) は latest 超でも保持する。 + // 詳細は should_skip_version のドキュメントコメントを参照) + if NpmAdapter::should_skip_version(&version, latest_version) { + continue; + } + + // このバージョンの公開時刻を取得 + if let Some(time_str) = self.time.get(&version) + && let Ok(released_at) = time_str.parse::>() + { + versions.push(VersionInfo::new(&version, released_at)); + } + } + + // バージョンでソート + versions.sort(); + + versions + } } impl NpmAdapter { @@ -128,32 +185,7 @@ impl RegistryAdapter for NpmAdapter { .get_json(&url, package, self.registry_name()) .await?; - // dist-tags から公式の "latest" バージョンを取得 - // npm が安定版とみなすバージョン - let latest_version = response.dist_tags.get("latest").map(|s| s.as_str()); - - let mut versions = Vec::new(); - - for (version, _) in response.versions { - // dist-tags.latest より新しい「安定版に見える」バージョンをスキップ - // (検出可能なプレリリース (canary/beta 等) は latest 超でも保持する。 - // 詳細は should_skip_version のドキュメントコメントを参照) - if Self::should_skip_version(&version, latest_version) { - continue; - } - - // このバージョンの公開時刻を取得 - if let Some(time_str) = response.time.get(&version) - && let Ok(released_at) = time_str.parse::>() - { - versions.push(VersionInfo::new(&version, released_at)); - } - } - - // バージョンでソート - versions.sort(); - - Ok(versions) + Ok(response.into_versions()) } } @@ -329,10 +361,9 @@ mod tests { assert!(!NpmAdapter::should_skip_version("19.3.0-canary.456", None)); } - /// versions の値 (packument メタデータ) は IgnoredAny で読み捨てられ、 - /// キーと dist-tags / time は正しくデシリアライズされる + /// 不要なメタデータは読み捨て、キーと dist-tags / time を保持する。 #[test] - fn test_deserialize_npm_response_ignores_version_values() { + fn test_deserialize_npm_response_ignores_unused_metadata() { let json = r#"{ "dist-tags": {"latest": "1.1.0"}, "time": { @@ -355,4 +386,172 @@ mod tests { assert!(response.versions.contains_key("1.1.0")); assert_eq!(response.time.len(), 3); } + + /// レジストリの非推奨メタデータを経由して minor 更新先を決める回帰テスト。 + #[test] + fn test_deprecated_release_is_not_selected_for_minor_update() { + use crate::domain::{ChangeLevel, UpdateResult}; + use crate::manifest::{ManifestParser, PackageJsonParser}; + use crate::update::{UpdateFilter, UpdateJudge}; + + let response: NpmPackageResponse = serde_json::from_value(serde_json::json!({ + "dist-tags": {"latest": "7.0.0"}, + "time": { + "6.4.6": "2024-01-01T00:00:00Z", + "6.9.1": "2024-02-01T00:00:00Z", + "6.10.0": "2024-03-01T00:00:00Z", + "7.0.0": "2024-04-01T00:00:00Z" + }, + "versions": { + "6.4.6": {}, "6.9.1": {}, + "6.10.0": {"deprecated": "Breaking changes; use 6.9.1 or 7.0.0"}, + "7.0.0": {} + } + })) + .unwrap(); + let versions = response.into_versions(); + let deps = PackageJsonParser + .parse(r#"{"dependencies":{"@testing-library/jest-dom":"^6.4.6"}}"#) + .unwrap(); + let judge = UpdateJudge::new(UpdateFilter::new().with_max_change(ChangeLevel::Minor)); + assert!(matches!(judge.judge(&deps[0], &versions), + UpdateResult::Update { new_version, .. } if new_version == "6.9.1")); + // 現在版が非推奨でも、候補に残った古い版へ下げない。 + let deps = PackageJsonParser + .parse(r#"{"dependencies":{"@testing-library/jest-dom":"^6.10.0"}}"#) + .unwrap(); + assert!(judge.judge(&deps[0], &versions).is_skip()); + } + + #[test] + fn test_all_deprecated_releases_leave_dependency_unchanged() { + use crate::manifest::{ManifestParser, PackageJsonParser}; + use crate::update::{UpdateFilter, UpdateJudge}; + + let response: NpmPackageResponse = serde_json::from_value(serde_json::json!({ + "dist-tags": {"latest": "1.1.0"}, + "time": {"1.0.0": "2024-01-01T00:00:00Z", "1.1.0": "2024-02-01T00:00:00Z"}, + "versions": { + "1.0.0": {"deprecated": "Unsupported"}, + "1.1.0": {"deprecated": "Unsupported"} + } + })) + .unwrap(); + let versions = response.into_versions(); + assert!(versions.is_empty()); + let deps = PackageJsonParser + .parse(r#"{"dependencies":{"pkg":"^1.0.0"}}"#) + .unwrap(); + assert!( + UpdateJudge::new(UpdateFilter::new()) + .judge(&deps[0], &versions) + .is_skip() + ); + } + + /// 指定解除・未指定を保持し、プレリリースでも非推奨なら除外する。 + #[test] + fn test_deprecation_metadata_without_latest_tag() { + let response: NpmPackageResponse = serde_json::from_value(serde_json::json!({ + "dist-tags": {}, + "time": { + "1.0.0": "2024-01-01T00:00:00Z", + "1.1.0": "2024-02-01T00:00:00Z", + "1.2.0": "2024-03-01T00:00:00Z", + "1.3.0-beta.1": "2024-04-01T00:00:00Z", + "1.3.0-beta.2": "2024-05-01T00:00:00Z", + "1.4.0": "invalid timestamp", + "1.6.0": "2024-06-01T00:00:00Z" + }, + "versions": { + "1.0.0": {"deprecated": null}, + "1.1.0": {"deprecated": ""}, + "1.2.0": {"dependencies": {"other": "^1"}, "dist": {"tarball": "..."}}, + "1.3.0-beta.1": {"deprecated": "Do not use"}, + "1.3.0-beta.2": {}, + "1.4.0": {}, "1.5.0": {}, "1.6.0": {"deprecated": " "} + } + })) + .unwrap(); + let versions = response.into_versions(); + assert_eq!( + versions + .iter() + .map(|v| v.version.as_str()) + .collect::>(), + ["1.0.0", "1.1.0", "1.2.0", "1.3.0-beta.2"] + ); + assert_eq!( + versions[1].released_at, + "2024-02-01T00:00:00Z".parse::>().unwrap() + ); + } + + #[test] + fn test_deprecation_filter_preserves_latest_and_prerelease_rules() { + let response: NpmPackageResponse = serde_json::from_value(serde_json::json!({ + "dist-tags": {"latest": "1.1.0"}, + "time": { + "1.0.0": "2024-01-01T00:00:00Z", + "1.1.0": "2024-02-01T00:00:00Z", + "1.2.0": "2024-03-01T00:00:00Z", + "2.0.0-beta.1": "2024-04-01T00:00:00Z", + "2.0.0-beta.2": "2024-05-01T00:00:00Z" + }, + "versions": { + "1.0.0": {}, "1.1.0": {"deprecated": "Use 1.0.0"}, + "1.2.0": {}, "2.0.0-beta.1": {}, + "2.0.0-beta.2": {"deprecated": "Broken prerelease"} + } + })) + .unwrap(); + assert_eq!( + response + .into_versions() + .iter() + .map(|v| v.version.as_str()) + .collect::>(), + ["1.0.0", "2.0.0-beta.1"] + ); + } + + #[test] + fn test_deprecated_values_follow_npm_truthiness() { + // 型違いの値があっても、同じ応答の通常版は取得できる。 + let mut manifests = serde_json::Map::new(); + let mut time = serde_json::Map::new(); + for (index, deprecated) in [ + Value::Null, + serde_json::json!(false), + serde_json::json!(0), + serde_json::json!(""), + serde_json::json!(true), + serde_json::json!(1), + serde_json::json!(-1), + serde_json::json!(" "), + serde_json::json!([]), + serde_json::json!({}), + ] + .into_iter() + .enumerate() + { + let version = format!("1.0.{index}"); + manifests.insert( + version.clone(), + serde_json::json!({"deprecated": deprecated}), + ); + time.insert(version, serde_json::json!("2024-01-01T00:00:00Z")); + } + let json = + serde_json::json!({"dist-tags": {}, "versions": manifests, "time": time}).to_string(); + let response: NpmPackageResponse = serde_json::from_str(&json).unwrap(); + assert_eq!( + response + .into_versions() + .iter() + .map(|v| v.version.as_str()) + .collect::>(), + ["1.0.0", "1.0.1", "1.0.2", "1.0.3"] + ); + } }