diff --git a/src-tauri/src/brief_facts_build.rs b/src-tauri/src/brief_facts_build.rs index af6218273..52966d122 100644 --- a/src-tauri/src/brief_facts_build.rs +++ b/src-tauri/src/brief_facts_build.rs @@ -415,10 +415,7 @@ fn with_direct_fix( &to, ) }) - .map(|f| match f { - crate::osv::direct_upgrade::DirectFix::Command(c) => format!("run `{c}`"), - crate::osv::direct_upgrade::DirectFix::Instruction(i) => i, - }); + .map(|f| f.brief_clause()); FixPath::Bump { to, fix } } diff --git a/src-tauri/src/brief_facts_build_tests.rs b/src-tauri/src/brief_facts_build_tests.rs index 34c8be8c2..573368060 100644 --- a/src-tauri/src/brief_facts_build_tests.rs +++ b/src-tauri/src/brief_facts_build_tests.rs @@ -32,7 +32,7 @@ fn a_bump_names_the_command_for_its_declaration() { ); assert_eq!( crate::brief_facts::fix_clause("minimist", &path), - "bump minimist to >= 1.2.6: run `npm install \"minimist@~1.2.6\"`" + "bump minimist to >= 1.2.6: within its declared range, run `npm update minimist`" ); // Not declared here: plain "bump", nothing invented. let plain = with_direct_fix( @@ -108,7 +108,10 @@ fn a_bump_without_a_confirmed_copy_or_known_ecosystem_names_nothing() { /// One fixture project, seeded the way the scanner seeds it: the Brief's /// direct row and the Upgrade Plan's direct row must name the SAME command /// (review of #982, SF3) — the property audit E8's two halves share. -fn seeded_project(tag: &str) -> Option<(tempfile::TempDir, String, crate::db::Database)> { +fn seeded_project( + tag: &str, + declared: &str, +) -> Option<(tempfile::TempDir, String, crate::db::Database)> { let dir = tempfile::Builder::new() .prefix(&format!("brief-{tag}-")) .tempdir_in(env!("CARGO_MANIFEST_DIR")) @@ -118,14 +121,22 @@ fn seeded_project(tag: &str) -> Option<(tempfile::TempDir, String, crate::db::Da eprintln!("SKIPPED brief/plan parity: {project} is excluded from DB writes here"); return None; } + let deps = serde_json::json!({ "minimist": declared }); std::fs::write( dir.path().join("package.json"), - r#"{"name": "app", "dependencies": {"minimist": "~1.2.5"}}"#, + serde_json::json!({ "name": "app", "dependencies": deps }).to_string(), ) .unwrap(); std::fs::write( dir.path().join("package-lock.json"), - r#"{"lockfileVersion": 3, "packages": {"": {"dependencies": {"minimist": "~1.2.5"}}, "node_modules/minimist": {"version": "1.2.5"}}}"#, + serde_json::json!({ + "lockfileVersion": 3, + "packages": { + "": { "dependencies": deps }, + "node_modules/minimist": { "version": "1.2.5" } + } + }) + .to_string(), ) .unwrap(); let db = crate::test_utils::test_db(); @@ -210,23 +221,33 @@ fn brief_clause(db: &crate::db::Database, project: &str) -> String { #[test] fn the_brief_and_the_plan_name_the_same_command() { - let Some((_dir, project, db)) = seeded_project("parity") else { - return; - }; - seed_copy(&db, &project); - let plan = crate::evidence::build_upgrade_plan(&db).items; - let row = plan - .iter() - .find(|i| i.title.contains("minimist")) - .expect("a minimist row"); - let command = "`npm install \"minimist@~1.2.6\"`"; - assert!( - row.explanation.contains(command), - "plan: {}", - row.explanation - ); - let clause = brief_clause(&db, &project); - assert!(clause.contains(command), "brief: {clause}"); + // An exact pin moves by the install that keeps it exact; a tilde that + // already admits the fix moves by the in-range refresh (#993). + for (tag, declared, command) in [ + ( + "parity", + "1.2.5", + "`npm install minimist@1.2.6 --save-exact`", + ), + ("parity-tilde", "~1.2.5", "`npm update minimist`"), + ] { + let Some((_dir, project, db)) = seeded_project(tag, declared) else { + return; + }; + seed_copy(&db, &project); + let plan = crate::evidence::build_upgrade_plan(&db).items; + let row = plan + .iter() + .find(|i| i.title.contains("minimist")) + .expect("a minimist row"); + assert!( + row.explanation.contains(command), + "{declared} plan: {}", + row.explanation + ); + let clause = brief_clause(&db, &project); + assert!(clause.contains(command), "{declared} brief: {clause}"); + } } /// SF1: with no version-confirmed copy the version and target are the @@ -234,7 +255,7 @@ fn the_brief_and_the_plan_name_the_same_command() { /// release rule); the plan names no command there, and neither does the Brief. #[test] fn without_a_confirmed_copy_the_brief_names_no_command() { - let Some((_dir, project, db)) = seeded_project("noconfirm") else { + let Some((_dir, project, db)) = seeded_project("noconfirm", "1.2.5") else { return; }; let clause = brief_clause(&db, &project); diff --git a/src-tauri/src/evidence/upgrade_parent_tests.rs b/src-tauri/src/evidence/upgrade_parent_tests.rs index 0157f4508..0fba7719a 100644 --- a/src-tauri/src/evidence/upgrade_parent_tests.rs +++ b/src-tauri/src/evidence/upgrade_parent_tests.rs @@ -568,6 +568,52 @@ fn a_direct_row_names_the_command_for_its_declaration() { eprintln!("SKIPPED a_direct_row_names_the_command_for_its_declaration: {project} is excluded from DB writes here"); return; } + std::fs::write( + dir.path().join("package.json"), + r#"{"name": "app", "dependencies": {"minimist": "1.2.5"}}"#, + ) + .unwrap(); + std::fs::write( + dir.path().join("package-lock.json"), + r#"{"lockfileVersion": 3, "packages": {"": {"dependencies": {"minimist": "1.2.5"}}, "node_modules/minimist": {"version": "1.2.5"}}}"#, + ) + .unwrap(); + let db = test_db(); + db.store_dependency(&project, "minimist", Some("1.2.5"), "npm", false, None) + .unwrap(); + db.store_dependency_instances( + &project, + "npm", + &[instance("minimist", "1.2.5", true, false)], + ) + .unwrap(); + advisory(&db, "GHSA-xvch-5gv4-984h", "minimist", "npm", "1.2.6", 9.8); + let plan = plan(&db); + let row = row(&plan, "minimist"); + validate_item(row).expect("schema-valid"); + assert!( + row.explanation.contains( + "Fixable now via a direct dependency bump: `npm install minimist@1.2.6 --save-exact`" + ), + "{}", + row.explanation + ); +} + +/// Live 2026-10-18 (navcal sharp, next, vite): a declared `~1.2.5` already admits the +/// fix 1.2.6, so the row names the in-range refresh, not an install that +/// rewrites the manifest. +#[test] +fn a_direct_row_whose_range_admits_the_fix_names_the_refresh() { + let dir = tempfile::Builder::new() + .prefix("direct-refresh-") + .tempdir_in(env!("CARGO_MANIFEST_DIR")) + .expect("tempdir"); + let project = dir.path().to_string_lossy().replace('\\', "/"); + if crate::project_inclusion::is_hard_excluded(&project) { + eprintln!("SKIPPED a_direct_row_whose_range_admits_the_fix_names_the_refresh: {project} is excluded from DB writes here"); + return; + } std::fs::write( dir.path().join("package.json"), r#"{"name": "app", "dependencies": {"minimist": "~1.2.5"}}"#, @@ -575,7 +621,7 @@ fn a_direct_row_names_the_command_for_its_declaration() { .unwrap(); std::fs::write( dir.path().join("package-lock.json"), - r#"{"lockfileVersion": 3, "packages": {"": {"dependencies": {"minimist": "~1.2.5"}}, "node_modules/minimist": {"version": "1.2.5"}}}"#, + r#"{"lockfileVersion": 3, "packages": {"": {"dependencies": {"minimist": "1.2.5"}}, "node_modules/minimist": {"version": "1.2.5"}}}"#, ) .unwrap(); let db = test_db(); @@ -593,7 +639,7 @@ fn a_direct_row_names_the_command_for_its_declaration() { validate_item(row).expect("schema-valid"); assert!( row.explanation.contains( - "Fixable now via a direct dependency bump: `npm install \"minimist@~1.2.6\"`" + "Fixable now with a lockfile refresh (the declared range already admits the fix): `npm update minimist`" ), "{}", row.explanation diff --git a/src-tauri/src/evidence/upgrade_plan.rs b/src-tauri/src/evidence/upgrade_plan.rs index df7ab6c98..f1a44150c 100644 --- a/src-tauri/src/evidence/upgrade_plan.rs +++ b/src-tauri/src/evidence/upgrade_plan.rs @@ -452,14 +452,19 @@ impl PackageGroup<'_> { .iter() .any(|l| l.target_version.is_some() && l.sites.iter().any(|s| !s.is_direct)); let how = crate::osv::direct_upgrade::direct_fix_note(&self.direct_fixes, m > 1); + // The declared range already admits the fix: a refresh, not a bump. + let lead = if crate::osv::direct_upgrade::all_refresh(&self.direct_fixes, &self.lines) { + "Fixable now with a lockfile refresh (the declared range already admits the fix)" + } else { + "Fixable now via a direct dependency bump" + }; if transitive { format!( - "Fixable now via a direct dependency bump where it is declared{how}. \ - Elsewhere it is transitive — {}", + "{lead} where it is declared{how}. Elsewhere it is transitive — {}", super::upgrade_parent::upstream_note(&self.package, &self.routes, m > 1) ) } else { - format!("Fixable now via a direct dependency bump{how}") + format!("{lead}{how}") } } else { super::upgrade_parent::upstream_note(&self.package, &self.routes, m > 1) diff --git a/src-tauri/src/osv/direct_upgrade.rs b/src-tauri/src/osv/direct_upgrade.rs index afba00561..e94f3b67c 100644 --- a/src-tauri/src/osv/direct_upgrade.rs +++ b/src-tauri/src/osv/direct_upgrade.rs @@ -16,12 +16,19 @@ //! - `cargo add` on a dev or build dependency writes a second //! `[dependencies]` entry unless given `--dev` / `--build`. //! +//! When the declared range already admits the target, the fix is the tool's +//! in-range lockfile refresh, which leaves the manifest as it is +//! ([`DirectFix::Refresh`], each form run 2026-10-18). Only a range bounded +//! below the next major (`^`, `~`, or a Cargo requirement) is refreshed: a +//! refresh installs the NEWEST release in range, which for `>=`, `||`, `*` or +//! an x-range could jump a major or land on a withdrawn or held release. +//! //! Anything this cannot read with certainty names nothing, and the plan keeps //! its plain "bump it where it is declared". That covers: //! - a dependency declared twice; //! - a declared spec that does not admit the installed copy; //! - a git, path or alternate-registry source; -//! - a `>=`/`||` range; +//! - a `>=`/`||`/`*`/x-range (neither a refresh nor an operator it can carry); //! - a yarn workspace member (`yarn up` is project-wide); //! - a Go `replace`; //! - hashed or pip-compiled requirements; @@ -35,8 +42,13 @@ use super::fix_target::LineTarget; /// What to tell the user, per declaring project. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DirectFix { - /// One command, run in the project's directory. + /// One command, run in the project's directory, that moves the declaration. Command(String), + /// The declared range already admits the target: a lockfile refresh, + /// run in the project's directory, moves the copy and leaves the + /// manifest as it is (live 2026-10-18: navcal's sharp, next and vite were + /// told to `npm install "x@^v"` though `^` already admitted the fix). + Refresh(String), /// A manifest edit no single verified command makes. Instruction(String), } @@ -45,10 +57,40 @@ impl DirectFix { /// The text shown in the plan: a command in backticks, an instruction as is. pub fn render(&self) -> String { match self { - Self::Command(c) => format!("`{c}`"), + Self::Command(c) | Self::Refresh(c) => format!("`{c}`"), Self::Instruction(i) => i.clone(), } } + + /// The text the Brief appends to "bump to >= : ". + pub fn brief_clause(&self) -> String { + match self { + Self::Command(c) => format!("run `{c}`"), + Self::Refresh(c) => format!("within its declared range, run `{c}`"), + Self::Instruction(i) => i.clone(), + } + } +} + +/// The in-range refresh of a DIRECT dependency, per tool: each was run on +/// 2026-10-18 against a project declaring `ms ^2.1.1` locked at 2.1.1 and +/// moved the lock to 2.1.3 with package.json unchanged (npm 10.9.2, pnpm +/// 10.34.6 also inside a workspace member, yarn 1.22.22, yarn 4.9.1; cargo +/// 1.97 kept `itoa = "1.0.5"` and locked 1.0.11). Plain `pnpm update x` +/// rewrote `^2.1.1` to `^2.1.3`, so pnpm needs `--no-save`. +fn direct_refresh_command( + manager: Manager, + package: &str, + installed: &str, + target: &str, +) -> String { + match manager { + Manager::Npm => format!("npm update {package}"), + Manager::Pnpm | Manager::PnpmWorkspace => format!("pnpm update {package} --no-save"), + Manager::YarnClassic => format!("yarn upgrade {package}"), + Manager::YarnBerry => format!("yarn up -R {package}"), + Manager::Cargo => format!("cargo update -p {package}@{installed} --precise {target}"), + } } /// The fix for `package` at `installed`, declared in the project at @@ -65,24 +107,14 @@ pub fn direct_fix( let project_dir = &on_disk(project_dir)?; let read = |file: &str| std::fs::read_to_string(project_dir.join(file)).ok(); match ecosystem_norm { - "npm" => { - let manager = Manager::detect(project_dir, "npm")?; - // yarn's `up`/`upgrade` act on the whole workspace: run from a - // member they rewrite siblings' declarations too. - let yarn = matches!(manager, Manager::YarnBerry | Manager::YarnClassic); - if yarn && !project_dir.join("yarn.lock").is_file() { - return None; - } - let declared = npm_declared(&read("package.json")?, package)?; - if requirement_admits(&declared, installed) != Some(true) { - return None; - } - npm_direct_command(manager, &declared, package, target).map(DirectFix::Command) - } - "crates.io" => match cargo_declarations(&read("Cargo.toml")?, package).as_slice() { - [only] if cargo_admits(only, installed) => cargo_direct(only, package, target), - _ => None, - }, + "npm" => npm_direct_fix( + project_dir, + &read("package.json")?, + package, + installed, + target, + ), + "crates.io" => cargo_direct_fix(&read("Cargo.toml")?, package, installed, target), "Go" => { go_direct_command(&read("go.mod")?, package, installed, target).map(DirectFix::Command) } @@ -98,6 +130,70 @@ pub fn direct_fix( } } +/// The npm-family fix. A declaration that admits the target with an upper +/// bound below the next major (`^`, `~`) gets the refresh; one whose range +/// excludes the target gets the install that keeps its operator. +fn npm_direct_fix( + project_dir: &Path, + package_json: &str, + package: &str, + installed: &str, + target: &str, +) -> Option { + let manager = Manager::detect(project_dir, "npm")?; + // yarn's `up`/`upgrade` act on the whole workspace: run from a member + // they rewrite siblings' declarations too. + let yarn = matches!(manager, Manager::YarnBerry | Manager::YarnClassic); + if yarn && !project_dir.join("yarn.lock").is_file() { + return None; + } + let declared = npm_declared(package_json, package)?; + if requirement_admits(&declared, installed) != Some(true) { + return None; + } + let bounded = matches!(npm_operator(&declared), Some("^" | "~")); + if bounded && requirement_admits(&declared, target) == Some(true) { + return Some(DirectFix::Refresh(direct_refresh_command( + manager, package, installed, target, + ))); + } + npm_direct_command(manager, &declared, package, target).map(DirectFix::Command) +} + +/// The Cargo fix for the one declaration of `package`. A requirement that +/// admits the target gets `cargo update --precise`, which pins the exact +/// target; a workspace-inherited, git, path or registry entry never does. +fn cargo_direct_fix( + manifest: &str, + package: &str, + installed: &str, + target: &str, +) -> Option { + let decls = cargo_declarations(manifest, package); + let [only] = decls.as_slice() else { + return None; + }; + if !cargo_admits(only, installed) { + return None; + } + let admits_target = !only.workspace + && !only.other_source + && only + .req + .as_deref() + .is_some_and(|r| Requirement::cargo(r).admits(target) == Some(true)); + if admits_target { + Some(DirectFix::Refresh(direct_refresh_command( + Manager::Cargo, + package, + installed, + target, + ))) + } else { + cargo_direct(only, package, target) + } +} + /// The spec `package` is declared with in a `package.json`, when exactly one /// section (`dependencies`, `devDependencies`, `optionalDependencies`) holds it. pub(crate) fn npm_declared(package_json: &str, package: &str) -> Option { @@ -483,19 +579,54 @@ pub fn direct_fixes( out } -/// ": `npm install ...`" for one project, or each fix with the project it runs in. +/// Every DIRECT site of the row got an in-range lockfile refresh: the row is +/// worded as a refresh, not a bump. A site whose fix could not be named +/// (it needs an unnamed bump) keeps the bump wording (#993 review S2). +pub fn all_refresh(fixes: &[(String, String, DirectFix)], lines: &[LineTarget]) -> bool { + let mut sites: Vec<(&str, &str)> = Vec::new(); + for line in lines { + let Some(target) = line.target_version.as_deref() else { + continue; + }; + for site in line.sites.iter().filter(|s| s.is_direct) { + if !sites.contains(&(site.project_path.as_str(), target)) { + sites.push((site.project_path.as_str(), target)); + } + } + } + !sites.is_empty() + && sites.len() == fixes.len() + && fixes + .iter() + .all(|(_, _, f)| matches!(f, DirectFix::Refresh(_))) +} + +/// ": `npm install ...`" for one project, or each fix with the project it runs +/// in. In a row that mixes bumps and refreshes, a refresh says what it is. pub fn direct_fix_note(fixes: &[(String, String, DirectFix)], multi_project: bool) -> String { if fixes.is_empty() { return String::new(); } + let mixed = !fixes + .iter() + .all(|(_, _, f)| matches!(f, DirectFix::Refresh(_))); let shown: Vec = fixes .iter() .take(MAX_DIRECT_FIXES) .map(|(project, _, fix)| { + let text = match fix { + DirectFix::Refresh(_) if mixed => { + format!( + "{} (a lockfile refresh: the declared range already admits it)", + fix.render() + ) + } + _ => fix.render(), + }; if multi_project { - format!("{} in {project}", fix.render()) + format!("{text} in {project}") } else { - fix.render() + text } }) .collect(); diff --git a/src-tauri/src/osv/direct_upgrade_tests.rs b/src-tauri/src/osv/direct_upgrade_tests.rs index 5c197cbb1..14580ba86 100644 --- a/src-tauri/src/osv/direct_upgrade_tests.rs +++ b/src-tauri/src/osv/direct_upgrade_tests.rs @@ -279,8 +279,12 @@ fn the_reader_uses_the_projects_own_manifest() { .unwrap(); assert_eq!( direct_fix(dir.path(), "npm", "minimist", "1.2.6", "1.2.8"), + Some(DirectFix::Refresh("npm update minimist".to_string())) + ); + assert_eq!( + direct_fix(dir.path(), "npm", "minimist", "1.2.6", "1.3.0"), Some(DirectFix::Command( - "npm install \"minimist@~1.2.8\"".to_string() + "npm install \"minimist@~1.3.0\"".to_string() )) ); assert_eq!( @@ -319,7 +323,13 @@ fn a_declaration_that_does_not_admit_the_installed_copy_names_nothing() { ); assert_eq!( direct_fix(dir.path(), "crates.io", "itoa", "1.0.6", "1.0.11"), - Some(DirectFix::Command("cargo add \"itoa@~1.0.11\"".to_string())) + Some(DirectFix::Refresh( + "cargo update -p itoa@1.0.6 --precise 1.0.11".to_string() + )) + ); + assert_eq!( + direct_fix(dir.path(), "crates.io", "itoa", "1.0.6", "1.1.0"), + Some(DirectFix::Command("cargo add \"itoa@~1.1.0\"".to_string())) ); } @@ -340,8 +350,12 @@ fn a_yarn_workspace_member_names_nothing() { ); assert_eq!( direct_fix(root.path(), "npm", "minimist", "1.2.6", "1.2.8"), + Some(DirectFix::Refresh("yarn upgrade minimist".to_string())) + ); + assert_eq!( + direct_fix(root.path(), "npm", "minimist", "1.2.6", "2.0.0"), Some(DirectFix::Command( - "yarn upgrade \"minimist@^1.2.8\"".to_string() + "yarn upgrade \"minimist@^2.0.0\"".to_string() )) ); } @@ -379,12 +393,123 @@ fn a_lowercased_project_path_reads_the_manifest_on_disk() { "1.2.5", "1.2.6" ), - Some(DirectFix::Command( - "npm install \"minimist@~1.2.6\"".to_string() - )) + Some(DirectFix::Refresh("npm update minimist".to_string())) ); assert_eq!( on_disk(std::path::Path::new(&format!("{lowered}/absent"))), None ); } + +/// Live 2026-10-18 (navcal): sharp, next and vite were told to +/// `npm install "x@^v"` though their `^` range already admitted the fix. When +/// the declaration admits the target, the advice is the tool's in-range +/// lockfile refresh, which leaves the manifest as it is. Each command below +/// was run on 2026-10-18 and kept package.json unchanged (pnpm needs +/// `--no-save`: plain `pnpm update` rewrote `^2.1.1` to `^2.1.3`). +#[test] +fn a_declaration_that_admits_the_target_gets_a_refresh_not_a_bump() { + let cases: &[(&str, &str, &str)] = &[ + ( + "package-lock.json", + "{\"lockfileVersion\": 3}", + "npm update ms", + ), + ( + "pnpm-lock.yaml", + "lockfileVersion: '9.0'\n", + "pnpm update ms --no-save", + ), + ("yarn.lock", "# yarn lockfile v1\n\n", "yarn upgrade ms"), + ( + "yarn.lock", + "# This file is generated by running \"yarn install\"\n\n__metadata:\n version: 8\n", + "yarn up -R ms", + ), + ]; + for (lockfile, body, want) in cases { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies": {"ms": "^2.1.1"}}"#, + ) + .unwrap(); + std::fs::write(dir.path().join(lockfile), body).unwrap(); + assert_eq!( + direct_fix(dir.path(), "npm", "ms", "2.1.1", "2.1.3"), + Some(DirectFix::Refresh((*want).to_string())), + "{lockfile}" + ); + } + // A `>=` range admits the target but has no upper bound: a refresh would + // install the NEWEST release in range (a major jump, or a withdrawn or held + // release), and there is no operator to carry, so it names nothing (#993 S3). + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies": {"ms": ">=2.0.0"}}"#, + ) + .unwrap(); + std::fs::write( + dir.path().join("package-lock.json"), + "{\"lockfileVersion\": 3}", + ) + .unwrap(); + assert_eq!(direct_fix(dir.path(), "npm", "ms", "2.1.1", "2.1.3"), None); +} + +/// The row wording follows: all refreshes read as a refresh; in a mixed row +/// the refresh says what it is; a direct site whose fix could not be named +/// keeps the bump wording (#993 review S2). +#[test] +fn the_note_words_a_refresh_as_one() { + use crate::osv::fix_target::{InstallSite, LineTarget}; + let line = |projects: &[&str]| LineTarget { + installed_version: "1.2.5".into(), + target_version: Some("1.2.8".into()), + clears_all_known: true, + upgrade_type: None, + sites: projects + .iter() + .map(|p| InstallSite { + project_path: (*p).to_string(), + is_direct: true, + is_dev: false, + }) + .collect(), + }; + let refresh = ( + "/a".to_string(), + "1.2.8".to_string(), + DirectFix::Refresh("npm update minimist".to_string()), + ); + let bump = ( + "/b".to_string(), + "1.2.8".to_string(), + DirectFix::Command("npm install minimist@1.2.8 --save-exact".to_string()), + ); + assert!(all_refresh( + std::slice::from_ref(&refresh), + &[line(&["/a"])] + )); + // /b is declared too, but its fix could not be named: still a bump row. + assert!(!all_refresh( + std::slice::from_ref(&refresh), + &[line(&["/a", "/b"])] + )); + assert_eq!( + direct_fix_note(std::slice::from_ref(&refresh), false), + ": `npm update minimist`" + ); + let mixed = direct_fix_note(&[refresh, bump], true); + assert!( + mixed.contains( + "`npm update minimist` (a lockfile refresh: the declared range already admits it) in /a" + ), + "{mixed}" + ); + assert!( + mixed.contains("`npm install minimist@1.2.8 --save-exact` in /b"), + "{mixed}" + ); +} diff --git a/src-tauri/src/osv/parent_release_load.rs b/src-tauri/src/osv/parent_release_load.rs index 335f6ea3d..19d73cc5c 100644 --- a/src-tauri/src/osv/parent_release_load.rs +++ b/src-tauri/src/osv/parent_release_load.rs @@ -424,10 +424,13 @@ fn annotate_link( } } -/// The command that moves a declared npm dependency, the operator kept. +/// The command that moves a declared npm dependency: the in-range refresh when +/// its declared range already admits the release (pr-001: `npm update +/// express`), else the install that keeps the operator. pub(super) fn lever_command(dir: &str, name: &str, installed: &str, to: &str) -> Option { match super::direct_upgrade::direct_fix(Path::new(dir), ECOSYSTEM, name, installed, to)? { - super::direct_upgrade::DirectFix::Command(c) => Some(c), + super::direct_upgrade::DirectFix::Command(c) + | super::direct_upgrade::DirectFix::Refresh(c) => Some(c), super::direct_upgrade::DirectFix::Instruction(_) => None, } } diff --git a/src-tauri/src/osv/parent_release_vectors.rs b/src-tauri/src/osv/parent_release_vectors.rs index c0007a6e9..fab12f643 100644 --- a/src-tauri/src/osv/parent_release_vectors.rs +++ b/src-tauri/src/osv/parent_release_vectors.rs @@ -129,9 +129,12 @@ fn command( "{\"lockfileVersion\": 3}\n", ) .map_err(|e| e.to_string())?; + // The command the lever names: the in-range refresh when the declaration + // already admits the release (pr-001), else the install that keeps the + // operator; exhaustive so a new kind of fix cannot drop out silently. Ok(match direct_fix(dir.path(), "npm", name, installed, to) { - Some(DirectFix::Command(c)) => Some(c), - _ => None, + Some(DirectFix::Command(c) | DirectFix::Refresh(c)) => Some(c), + Some(DirectFix::Instruction(_)) | None => None, }) } diff --git a/src-tauri/src/osv/vectors_tests.rs b/src-tauri/src/osv/vectors_tests.rs index 729d1419f..fba33d17b 100644 --- a/src-tauri/src/osv/vectors_tests.rs +++ b/src-tauri/src/osv/vectors_tests.rs @@ -416,13 +416,15 @@ fn check_direct(v: &Value, root: &std::path::Path) -> Result<(), String> { s(v, "installed"), s(v, "target"), ); - let (command, instruction) = match &fix { - Some(DirectFix::Command(c)) => (Some(c.as_str()), None), - Some(DirectFix::Instruction(i)) => (None, Some(i.as_str())), - None => (None, None), + let (command, instruction, refresh) = match &fix { + Some(DirectFix::Command(c)) => (Some(c.as_str()), None, false), + Some(DirectFix::Refresh(c)) => (Some(c.as_str()), None, true), + Some(DirectFix::Instruction(i)) => (None, Some(i.as_str()), false), + None => (None, None, false), }; let (want_c, want_i) = (opt_s(v, "command"), opt_s(v, "instruction")); - if command == want_c && instruction == want_i { + let want_refresh = v.get("refresh").and_then(Value::as_bool).unwrap_or(false); + if command == want_c && instruction == want_i && refresh == want_refresh { Ok(()) } else { Err(format!( diff --git a/src-tauri/tests/conformance/vectors/README.md b/src-tauri/tests/conformance/vectors/README.md index 28b7b5731..250a564d1 100644 --- a/src-tauri/tests/conformance/vectors/README.md +++ b/src-tauri/tests/conformance/vectors/README.md @@ -245,6 +245,7 @@ form. - no command when the declared spec does not admit the installed copy: fc-046 (npm), fc-047 (Cargo); - no command for a yarn workspace member, because `yarn upgrade` / `yarn up` rewrite every workspace: fc-053. - A direct Cargo dependency: `cargo add`, with `--dev` / `--build` / `--target` / `--rename` from the declaring table and the operator kept (fc-037..fc-042; `--target` double-quoted, because cmd.exe passes single quotes through: fc-040); an instruction to edit the workspace root for a workspace-inherited crate, where `cargo add` replaced the inheritance with a local pin (fc-043); nothing for a git, path or alternate-registry source (fc-048). +- A direct dependency whose declared range already admits the target gets the tool's in-range lockfile refresh, never an install that rewrites the declaration (`refresh: true`; each run 2026-10-18 with package.json unchanged): `npm update x` (fc-054), `pnpm update x --no-save` (fc-055; plain `pnpm update` rewrote the range), `yarn upgrade x` (fc-056), `yarn up -R x` (fc-057), `cargo update -p x@v --precise t` (fc-058), an npm and a pnpm workspace member, run in the member's directory (fc-060, fc-061). Only a range bounded below the next major (`^`, `~`) is refreshed, because a refresh installs the newest release in range; a `>=` range names nothing (fc-059). - A direct Go requirement: `go get mod@vX`; none for an indirect one or one under a `replace`: fc-044, fc-045, fc-049. - A direct PyPI `==` pin: an instruction that replaces only the version token and keeps extras and the environment marker (fc-050); nothing for a hashed pin (fc-051) or pip-compile output (fc-052). diff --git a/src-tauri/tests/conformance/vectors/fix_command.jsonl b/src-tauri/tests/conformance/vectors/fix_command.jsonl index af6c6b6cc..672c14581 100644 --- a/src-tauri/tests/conformance/vectors/fix_command.jsonl +++ b/src-tauri/tests/conformance/vectors/fix_command.jsonl @@ -26,19 +26,19 @@ {"id":"fc-026","rule":"AD-055 ¶4: commands are named only for tools 4DA can stand behind; bundler is not one","source":"AD-055 ¶4 (command text)","tool":"bundler","eco":"RubyGems","pkg":"rack","installed":"1.0.0","target":"1.0.1","workspace_root":false,"command":null} {"id":"fc-027","rule":"AD-055 ¶4: commands are named only for tools 4DA can stand behind; composer is not one","source":"AD-055 ¶4 (command text)","tool":"composer","eco":"Packagist","pkg":"symfony/http-kernel","installed":"1.0.0","target":"1.0.1","workspace_root":false,"command":null} {"id":"fc-028","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — npm: an exact pin needs --save-exact (plain `npm install x@v` wrote ^v)","source":"npm 10.9.2, 2026-10-15, on a project declaring `1.2.5`: `npm install minimist@1.2.8 --save-exact` moved minimist to 1.2.8 and kept how it is declared (operator and section)","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":"1.2.5","installed":"1.2.5","target":"1.2.8","workspace_root":false,"command":"npm install minimist@1.2.8 --save-exact"} -{"id":"fc-029","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — npm: the caret, quoted (cmd.exe reads ^ as an escape)","source":"npm 10.9.2, 2026-10-15, on a project declaring `^1.2.5`: `npm install \"minimist@^1.2.8\"` moved minimist to 1.2.8 and kept how it is declared (operator and section)","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":"^1.2.5","installed":"1.2.5","target":"1.2.8","workspace_root":false,"command":"npm install \"minimist@^1.2.8\""} -{"id":"fc-030","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — npm: the tilde is kept (plain `npm install` wrote ^2.1.3 over ~2.1.1)","source":"npm 10.9.2, 2026-10-15, on a project declaring `~2.1.1`: `npm install \"ms@~2.1.3\"` moved ms to 2.1.3 and kept how it is declared (operator and section)","kind":"direct","tool":"npm","eco":"npm","pkg":"ms","declared":"~2.1.1","installed":"2.1.1","target":"2.1.3","workspace_root":false,"command":"npm install \"ms@~2.1.3\""} -{"id":"fc-031","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — npm: a scoped name verbatim","source":"npm 10.9.2, 2026-10-15, on a project declaring `^7.20.5`: `npm install \"@babel/traverse@^7.23.2\"` moved @babel/traverse to 7.23.2 and kept how it is declared (operator and section)","kind":"direct","tool":"npm","eco":"npm","pkg":"@babel/traverse","declared":"^7.20.5","installed":"7.20.5","target":"7.23.2","workspace_root":false,"command":"npm install \"@babel/traverse@^7.23.2\""} -{"id":"fc-032","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — pnpm up, with the declared operator (also right at a workspace root, where `pnpm add` needs -w)","source":"pnpm 10.34.6, 2026-10-15, on a project declaring `~2.1.1`: `pnpm up \"ms@~2.1.3\"` moved ms to 2.1.3 and kept how it is declared (operator and section)","kind":"direct","tool":"pnpm","eco":"npm","pkg":"ms","declared":"~2.1.1","installed":"2.1.1","target":"2.1.3","workspace_root":false,"command":"pnpm up \"ms@~2.1.3\""} +{"id":"fc-029","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — npm: the caret, quoted (cmd.exe reads ^ as an escape)","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; npm 10.9.2 (--package-lock-only): declared ^0.2.1, `npm install \"minimist@^1.2.8\"` wrote ^1.2.8 (the caret kept)","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":"^0.2.1","installed":"0.2.1","target":"1.2.8","workspace_root":false,"command":"npm install \"minimist@^1.2.8\""} +{"id":"fc-030","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — npm: the tilde is kept (plain `npm install` wrote ^2.1.3 over ~2.1.1)","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; npm 10.9.2 (--package-lock-only): declared ~2.0.0, `npm install \"ms@~2.1.3\"` wrote ~2.1.3 (the tilde kept; plain `npm install x@v` writes ^v, 2026-10-15)","kind":"direct","tool":"npm","eco":"npm","pkg":"ms","declared":"~2.0.0","installed":"2.0.0","target":"2.1.3","workspace_root":false,"command":"npm install \"ms@~2.1.3\""} +{"id":"fc-031","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — npm: a scoped name verbatim","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; npm 10.9.2 (--package-lock-only): declared ^7.20.5, `npm install \"@babel/traverse@^8.0.0\"` wrote ^8.0.7 (the caret kept; npm writes the resolved release, 8.0.7 that day)","kind":"direct","tool":"npm","eco":"npm","pkg":"@babel/traverse","declared":"^7.20.5","installed":"7.20.5","target":"8.0.0","workspace_root":false,"command":"npm install \"@babel/traverse@^8.0.0\""} +{"id":"fc-032","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — pnpm up, with the declared operator (also right at a workspace root, where `pnpm add` needs -w)","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; pnpm 10.34.6: declared ~2.0.0, `pnpm up \"ms@~2.1.3\"` wrote ~2.1.3 and locked 2.1.3","kind":"direct","tool":"pnpm","eco":"npm","pkg":"ms","declared":"~2.0.0","installed":"2.0.0","target":"2.1.3","workspace_root":false,"command":"pnpm up \"ms@~2.1.3\""} {"id":"fc-033","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — pnpm: an exact pin stays exact","source":"pnpm 10.34.6, 2026-10-15, on a project declaring `7.5.0`: `pnpm up semver@7.5.4` moved semver to 7.5.4 and kept how it is declared (operator and section)","kind":"direct","tool":"pnpm","eco":"npm","pkg":"semver","declared":"7.5.0","installed":"7.5.0","target":"7.5.4","workspace_root":false,"command":"pnpm up semver@7.5.4"} -{"id":"fc-034","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — yarn 1: `yarn upgrade x@v` wrote an exact pin over ~; the operator keeps it","source":"yarn 1.22.22, 2026-10-15, on a project declaring `~2.1.1`: `yarn upgrade \"ms@~2.1.3\"` moved ms to 2.1.3 and kept how it is declared (operator and section)","kind":"direct","tool":"yarn1","eco":"npm","pkg":"ms","declared":"~2.1.1","installed":"2.1.1","target":"2.1.3","workspace_root":false,"command":"yarn upgrade \"ms@~2.1.3\""} -{"id":"fc-035","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — yarn 4: `yarn up x@v` wrote an exact pin over ^; the operator keeps it","source":"yarn 4.9.1 (corepack), 2026-10-15, on a project declaring `^1.2.5`: `yarn up \"minimist@^1.2.8\"` moved minimist to 1.2.8 and kept how it is declared (operator and section)","kind":"direct","tool":"yarn-berry","eco":"npm","pkg":"minimist","declared":"^1.2.5","installed":"1.2.5","target":"1.2.8","workspace_root":false,"command":"yarn up \"minimist@^1.2.8\""} -{"id":"fc-036","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — a spec whose operator cannot be carried (>=, ||, x-ranges, tags, git, workspace:) names no command","source":"Rule, not a tool run: `>=1.2.0` has no operator a command can carry (npm would write `^1.2.8`), so the plan keeps its plain 'bump it where it is declared'","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":">=1.2.0","installed":"1.2.0","target":"1.2.8","workspace_root":false,"command":null} -{"id":"fc-037","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo add keeps the entry's features","source":"cargo 1.95, 2026-10-15, on a project declaring `[dependencies] serde = { version = \"1.0.100\", features = [\"derive\"] }`: `cargo add serde@1.0.200` moved serde to 1.0.200 and kept how it is declared (operator and section)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"serde","manifest":"[dependencies]\nserde = { version = \"1.0.100\", features = [\"derive\"] }\n","installed":"1.0.100","target":"1.0.200","workspace_root":false,"command":"cargo add serde@1.0.200"} -{"id":"fc-038","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: --dev for a dev-dependency (without it cargo wrote a second [dependencies] entry), operator kept","source":"cargo 1.95, 2026-10-15, on a project declaring `[dev-dependencies] ryu = \"~1.0.5\"`: `cargo add \"ryu@~1.0.15\" --dev` moved ryu to 1.0.15 and kept how it is declared (operator and section)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"ryu","manifest":"[dev-dependencies]\nryu = \"~1.0.5\"\n","installed":"1.0.5","target":"1.0.15","workspace_root":false,"command":"cargo add \"ryu@~1.0.15\" --dev"} -{"id":"fc-039","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: --build for a build-dependency","source":"cargo 1.95, 2026-10-15, on a project declaring `[build-dependencies] cc = \"1.0.50\"`: `cargo add cc@1.0.83 --build` moved cc to 1.0.83 and kept how it is declared (operator and section)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"cc","manifest":"[build-dependencies]\ncc = \"1.0.50\"\n","installed":"1.0.50","target":"1.0.83","workspace_root":false,"command":"cargo add cc@1.0.83 --build"} -{"id":"fc-040","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: --target for a target-specific dependency","source":"cargo 1.95, 2026-10-15, on a crate declaring `[target.'cfg(windows)'.dependencies] winapi = \"0.3.8\"`: the command moved winapi to 0.3.9 in the same table. Re-run through cmd.exe 2026-10-17 (toolchain resolving for Rust 1.97): with double quotes it moved winapi to 0.3.9; with single quotes cargo failed: failed to parse `'cfg(windows)'` as a cfg expression","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"winapi","manifest":"[target.'cfg(windows)'.dependencies]\nwinapi = \"0.3.8\"\n","installed":"0.3.8","target":"0.3.9","workspace_root":false,"command":"cargo add winapi@0.3.9 --target \"cfg(windows)\""} -{"id":"fc-041","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: --rename for a renamed dependency","source":"cargo 1.95, 2026-10-15, on a project declaring `[dependencies] myitoa = { package = \"itoa\", version = \"1.0.1\" }`: `cargo add itoa@1.0.11 --rename myitoa` moved itoa to 1.0.11 and kept how it is declared (operator and section)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"itoa","manifest":"[dependencies]\nmyitoa = { package = \"itoa\", version = \"1.0.1\" }\n","installed":"1.0.1","target":"1.0.11","workspace_root":false,"command":"cargo add itoa@1.0.11 --rename myitoa"} +{"id":"fc-034","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — yarn 1: `yarn upgrade x@v` wrote an exact pin over ~; the operator keeps it","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; yarn 1.22.22: declared ~2.0.0, `yarn upgrade \"ms@~2.1.3\"` wrote ~2.1.3 and locked 2.1.3","kind":"direct","tool":"yarn1","eco":"npm","pkg":"ms","declared":"~2.0.0","installed":"2.0.0","target":"2.1.3","workspace_root":false,"command":"yarn upgrade \"ms@~2.1.3\""} +{"id":"fc-035","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — yarn 4: `yarn up x@v` wrote an exact pin over ^; the operator keeps it","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; yarn 4.9.1 (corepack): declared ^0.2.1, `yarn up \"minimist@^1.2.8\"` wrote ^1.2.8","kind":"direct","tool":"yarn-berry","eco":"npm","pkg":"minimist","declared":"^0.2.1","installed":"0.2.1","target":"1.2.8","workspace_root":false,"command":"yarn up \"minimist@^1.2.8\""} +{"id":"fc-036","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — a spec whose operator cannot be carried (>=, ||, x-ranges, tags, git, workspace:) names no command","source":"Rule, not a tool run: `0.2.x` has no operator a command can carry and does not admit the real release 1.2.8, so the plan keeps its plain 'bump it where it is declared'","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":"0.2.x","installed":"0.2.1","target":"1.2.8","workspace_root":false,"command":null} +{"id":"fc-037","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo add keeps the entry's features","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; cargo 1.97: `rand = { version = \"0.7.3\", features = [\"small_rng\"] }`, `cargo add rand@0.8.5` wrote version \"0.8.5\" and kept features = [\"small_rng\"]","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"rand","manifest":"[dependencies]\nrand = { version = \"0.7.3\", features = [\"small_rng\"] }\n","installed":"0.7.3","target":"0.8.5","workspace_root":false,"command":"cargo add rand@0.8.5"} +{"id":"fc-038","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: --dev for a dev-dependency (without it cargo wrote a second [dependencies] entry), operator kept","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; cargo 1.97: [dev-dependencies] ryu = \"~0.2.8\", `cargo add \"ryu@~1.0.20\" --dev` wrote ryu = \"~1.0.20\" in [dev-dependencies] (without --dev cargo wrote a second [dependencies] entry, 2026-10-15)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"ryu","manifest":"[dev-dependencies]\nryu = \"~0.2.8\"\n","installed":"0.2.8","target":"1.0.20","workspace_root":false,"command":"cargo add \"ryu@~1.0.20\" --dev"} +{"id":"fc-039","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: --build for a build-dependency","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; cargo 1.97: [build-dependencies] autocfg = \"0.1.8\", `cargo add autocfg@1.4.0 --build` wrote autocfg = \"1.4.0\" in [build-dependencies]","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"autocfg","manifest":"[build-dependencies]\nautocfg = \"0.1.8\"\n","installed":"0.1.8","target":"1.4.0","workspace_root":false,"command":"cargo add autocfg@1.4.0 --build"} +{"id":"fc-040","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: --target for a target-specific dependency","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; cargo 1.97: [target.'cfg(windows)'.dependencies] winapi = \"0.2.8\", `cargo add winapi@0.3.9 --target \"cfg(windows)\"` wrote winapi = \"0.3.9\" in the same table. Double quotes: through cmd.exe, single quotes failed to parse as a cfg expression (2026-10-17)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"winapi","manifest":"[target.'cfg(windows)'.dependencies]\nwinapi = \"0.2.8\"\n","installed":"0.2.8","target":"0.3.9","workspace_root":false,"command":"cargo add winapi@0.3.9 --target \"cfg(windows)\""} +{"id":"fc-041","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: --rename for a renamed dependency","source":"Re-grounded 2026-10-18 (#993 review): real releases, the declaration chosen so the target is outside its range; cargo 1.97: `myitoa = { package = \"itoa\", version = \"0.4.8\" }`, `cargo add itoa@1.0.11 --rename myitoa` wrote version \"1.0.11\" under the same key","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"itoa","manifest":"[dependencies]\nmyitoa = { package = \"itoa\", version = \"0.4.8\" }\n","installed":"0.4.8","target":"1.0.11","workspace_root":false,"command":"cargo add itoa@1.0.11 --rename myitoa"} {"id":"fc-042","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: an = pin stays exact","source":"cargo 1.95, 2026-10-15, on a project declaring `[dependencies] exact = \"=1.0.1\"`: `cargo add \"exact@=1.0.11\"` moved exact to 1.0.11 and kept how it is declared (operator and section)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"exact","manifest":"[dependencies]\nexact = \"=1.0.1\"\n","installed":"1.0.1","target":"1.0.11","workspace_root":false,"command":"cargo add \"exact@=1.0.11\""} {"id":"fc-043","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — cargo: a workspace-inherited dependency gets no command (cargo add replaced the inheritance with a local pin); the workspace root must move","source":"cargo 1.95, 2026-10-15: `cargo add tokio@1.38.2` in a member declaring `tokio.workspace = true` replaced the inheritance with `tokio = \"1.38.2\"`, so the plan names the root edit instead","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"tokio","manifest":"[dependencies]\ntokio.workspace = true\n","installed":"1.38.0","target":"1.38.2","workspace_root":false,"command":null,"instruction":"bump tokio to 1.38.2 in the workspace root's [workspace.dependencies] (it is inherited here; `cargo add` in this crate would replace the inheritance with a local pin)"} {"id":"fc-044","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — go get @v for a direct requirement; none for an indirect one","source":"go 1.24, 2026-10-15, on a project declaring `require golang.org/x/text v0.3.7`: `go get golang.org/x/text@v0.3.8` moved golang.org/x/text to 0.3.8 and kept how it is declared (operator and section)","kind":"direct","tool":"go","eco":"Go","pkg":"golang.org/x/text","go_mod":"module example.com/m\n\nrequire golang.org/x/text v0.3.7\n","installed":"0.3.7","target":"0.3.8","workspace_root":false,"command":"go get golang.org/x/text@v0.3.8"} @@ -51,3 +51,11 @@ {"id":"fc-051","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — PyPI: a hashed pin names nothing (its --hash no longer matches once the version changes)","source":"Rule from the review of #972 (2026-10-17), not a tool run: pip --require-hashes rejects a version whose hash is not listed","kind":"direct","tool":"pip","eco":"PyPI","pkg":"django","requirements":"django==4.2 \\\n --hash=sha256:abc\n","installed":"4.2","target":"4.2.17","workspace_root":false,"command":null} {"id":"fc-052","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — PyPI: pip-compile output names nothing (edit its .in source and recompile)","source":"Rule from the review of #972 (2026-10-17), not a tool run: pip-compile regenerates the file and would revert a hand edit","kind":"direct","tool":"pip","eco":"PyPI","pkg":"django","requirements":"#\n# This file is autogenerated by pip-compile with Python 3.12\n#\ndjango==4.2\n","installed":"4.2","target":"4.2.17","workspace_root":false,"command":null} {"id":"fc-053","rule":"audit E8: a DIRECT dependency gets the command that moves it where it is declared, keeping how it is declared — yarn: a workspace member names nothing (`yarn upgrade` / `yarn up` act on the whole workspace)","source":"Rule from the review of #972 (2026-10-17), not a tool run: yarn 1 and berry resolve the spec across every workspace, rewriting siblings' declarations","kind":"direct","tool":"yarn1","eco":"npm","pkg":"minimist","declared":"^1.2.5","member":true,"installed":"1.2.6","target":"1.2.8","workspace_root":false,"command":null} +{"id":"fc-054","rule":"audit E8 follow-up (recovery-lead live check, 2026-10-18): when the DECLARED range of a direct dependency already admits the fix target, the fix is the tool's in-range lockfile refresh, which leaves the manifest as it is, never an install that rewrites the declaration — npm: `npm update `","source":"npm 10.9.2. Live check 2026-10-18 on a project declaring `ms ^2.1.1` with the lock at 2.1.1: the command moved the lock to 2.1.3 and package.json was unchanged","kind":"direct","tool":"npm","eco":"npm","pkg":"ms","declared":"^2.1.1","installed":"2.1.1","target":"2.1.3","command":"npm update ms","workspace_root":false,"refresh":true} +{"id":"fc-055","rule":"audit E8 follow-up (recovery-lead live check, 2026-10-18): when the DECLARED range of a direct dependency already admits the fix target, the fix is the tool's in-range lockfile refresh, which leaves the manifest as it is, never an install that rewrites the declaration — pnpm: `pnpm update --no-save` (plain `pnpm update ms` rewrote `^2.1.1` to `^2.1.3`); also right inside a workspace member, where only that member's importer moved","source":"pnpm 10.34.6. Live check 2026-10-18 on a project declaring `ms ^2.1.1` with the lock at 2.1.1: the command moved the lock to 2.1.3 and package.json was unchanged; plain `pnpm update ms` rewrote the declaration to `^2.1.3`","kind":"direct","tool":"pnpm","eco":"npm","pkg":"ms","declared":"^2.1.1","installed":"2.1.1","target":"2.1.3","command":"pnpm update ms --no-save","workspace_root":false,"refresh":true} +{"id":"fc-056","rule":"audit E8 follow-up (recovery-lead live check, 2026-10-18): when the DECLARED range of a direct dependency already admits the fix target, the fix is the tool's in-range lockfile refresh, which leaves the manifest as it is, never an install that rewrites the declaration — yarn 1: `yarn upgrade ` with no version","source":"yarn 1.22.22. Live check 2026-10-18 on a project declaring `ms ^2.1.1` with the lock at 2.1.1: the command moved the lock to 2.1.3 and package.json was unchanged","kind":"direct","tool":"yarn1","eco":"npm","pkg":"ms","declared":"^2.1.1","installed":"2.1.1","target":"2.1.3","command":"yarn upgrade ms","workspace_root":false,"refresh":true} +{"id":"fc-057","rule":"audit E8 follow-up (recovery-lead live check, 2026-10-18): when the DECLARED range of a direct dependency already admits the fix target, the fix is the tool's in-range lockfile refresh, which leaves the manifest as it is, never an install that rewrites the declaration — yarn berry: `yarn up -R `","source":"yarn 4.9.1 (corepack). Live check 2026-10-18 on a project declaring `ms ^2.1.1` with the lock at 2.1.1: the command moved the lock to 2.1.3 and package.json was unchanged","kind":"direct","tool":"yarn-berry","eco":"npm","pkg":"ms","declared":"^2.1.1","installed":"2.1.1","target":"2.1.3","command":"yarn up -R ms","workspace_root":false,"refresh":true} +{"id":"fc-058","rule":"audit E8 follow-up (recovery-lead live check, 2026-10-18): when the DECLARED range of a direct dependency already admits the fix target, the fix is the tool's in-range lockfile refresh, which leaves the manifest as it is, never an install that rewrites the declaration — Cargo: `cargo update -p @ --precise `","source":"cargo 1.97, 2026-10-18, on a crate declaring `itoa = \"1.0.5\"` locked at 1.0.5: `cargo update -p itoa@1.0.5 --precise 1.0.11` (the text named here) locked 1.0.11 and Cargo.toml was unchanged","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"itoa","manifest":"[dependencies]\nitoa = \"1.0.5\"\n","installed":"1.0.5","target":"1.0.11","command":"cargo update -p itoa@1.0.5 --precise 1.0.11","workspace_root":false,"refresh":true} +{"id":"fc-059","rule":"audit E8 follow-up (recovery-lead live check, 2026-10-18): when the DECLARED range of a direct dependency already admits the fix target, the fix is the tool's in-range lockfile refresh, which leaves the manifest as it is, never an install that rewrites the declaration — only a range bounded below the next major (`^`, `~`) is refreshed: a refresh installs the NEWEST release in range, so a `>=` / `||` / `*` / x-range could jump a major or land on a withdrawn or held release; such a range names nothing (#993 review S3)","source":"Rule, not a tool run: `>=1.2.0` admits 1.2.8 but has no upper bound","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":">=1.2.0","installed":"1.2.5","target":"1.2.8","command":null,"workspace_root":false} +{"id":"fc-060","rule":"audit E8 follow-up (recovery-lead live check, 2026-10-18): when the DECLARED range of a direct dependency already admits the fix target, the fix is the tool's in-range lockfile refresh — npm: also for a workspace member, run in the member's directory (unlike yarn, whose up/upgrade act on the whole workspace)","source":"npm 10.9.2, 2026-10-18: root {\"workspaces\":[\"packages/*\"]}, packages/a declaring ms ^2.1.1 locked at 2.1.1; `npm update ms` in packages/a moved the member's copy to 2.1.3 with its declaration unchanged, both hoisted (node_modules/ms) and nested (root pinning ms 2.0.0, packages/a/node_modules/ms)","kind":"direct","tool":"npm","eco":"npm","pkg":"ms","declared":"^2.1.1","member":true,"installed":"2.1.1","target":"2.1.3","workspace_root":true,"command":"npm update ms","refresh":true} +{"id":"fc-061","rule":"audit E8 follow-up (recovery-lead live check, 2026-10-18): when the DECLARED range of a direct dependency already admits the fix target, the fix is the tool's in-range lockfile refresh — pnpm: also for a workspace member, run in the member's directory","source":"pnpm 10.34.6, 2026-10-18: pnpm-workspace.yaml with packages/app and packages/other both declaring ms ^2.1.1 locked at 2.1.1; `pnpm update ms --no-save` in packages/app moved only app's importer to 2.1.3, other stayed 2.1.1, declarations unchanged","kind":"direct","tool":"pnpm","eco":"npm","pkg":"ms","declared":"^2.1.1","member":true,"installed":"2.1.1","target":"2.1.3","workspace_root":true,"command":"pnpm update ms --no-save","refresh":true} diff --git a/src-tauri/tests/conformance/vectors/parent_release.jsonl b/src-tauri/tests/conformance/vectors/parent_release.jsonl index c6d408c66..f22ed8269 100644 --- a/src-tauri/tests/conformance/vectors/parent_release.jsonl +++ b/src-tauri/tests/conformance/vectors/parent_release.jsonl @@ -1,4 +1,4 @@ -{"id":"pr-001","rule":"A parent with its own vulnerable row moves once: its row target is raised to the highest release a transitive row needs from it (qs needs express 4.22.3), and a row whose smallest admitting release is lower (path-to-regexp: 4.22.0) is lifted to that target when it still admits a clean copy there. One release, one command, across rows (audit H1, rules a, b, e)","source":"OWASP/NodeGoat package-lock.json (express 4.16.4 declared ^4.13.4, path-to-regexp 0.1.7, qs 6.5.2); registry.npmjs.org abbreviated documents (Accept: application/vnd.npm.install-v1+json) and OSV.dev /v1/query, read 2026-10-18; the same answer as @4da/mcp-server fix/planner-advice-625 1840dab upgrade_planner on NodeGoat (express 4.22.3 on the express, path-to-regexp and qs rows)","eco":"npm","tool":"npm","parent":{"name":"express","installed":"4.16.4","declared":"^4.13.4","lever":"direct","latest":"5.3.0","deprecated":false,"releases":["4.16.4","4.17.0","4.17.1","4.17.2","4.17.3","4.18.0","4.18.1","4.18.2","4.18.3","4.19.0","4.19.1","4.19.2","4.20.0","4.21.0","4.21.1","4.21.2","4.22.0","4.22.1","4.22.2","4.22.3","5.0.0"],"advisories":[{"id":"GHSA-cm5g-3pgc-8rg4","ranges":[{"introduced":"0","fixed":"4.0.0-rc1"}]},{"id":"GHSA-gpvr-g6gh-9mc2","ranges":[{"introduced":"0","fixed":"3.11.0"},{"introduced":"4.0.0","fixed":"4.5.0"}]},{"id":"GHSA-jj78-5fmv-mv28","ranges":[{"introduced":"3.4.5","fixed":"4.0.0-rc1"}]},{"id":"GHSA-qw6h-vgh9-j6wx","ranges":[{"introduced":"0","fixed":"4.20.0"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0"}]},{"id":"GHSA-rv95-896h-c2vc","ranges":[{"introduced":"0","fixed":"4.19.2"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0-beta.3"}]}],"own_target":"4.20.0"},"rows":[{"pkg":"path-to-regexp","installed":"0.1.7","target":"0.1.13","releases":["0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.1.5","0.1.6","0.1.7","0.1.8","0.1.9","0.1.10","0.1.11","0.1.12","0.1.13","8.0.0","8.1.0","8.2.0","8.3.0","8.4.0","8.4.1","8.4.2"],"advisories":[{"id":"GHSA-27v5-c462-wpq7","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-37ch-88jc-xwx2","ranges":[{"introduced":"0","fixed":"0.1.13"}]},{"id":"GHSA-9wv6-86v2-598j","ranges":[{"introduced":"0.2.0","fixed":"1.9.0"},{"introduced":"0","fixed":"0.1.10"},{"introduced":"7.0.0","fixed":"8.0.0"},{"introduced":"2.0.0","fixed":"3.3.0"},{"introduced":"4.0.0","fixed":"6.3.0"}]},{"id":"GHSA-j3q9-mxjg-w52f","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-rhx6-c78j-4q9w","ranges":[{"introduced":"0","fixed":"0.1.12"}]}],"requires":{"4.16.4":"0.1.7","4.17.0":"0.1.7","4.17.1":"0.1.7","4.17.2":"0.1.7","4.17.3":"0.1.7","4.18.0":"0.1.7","4.18.1":"0.1.7","4.18.2":"0.1.7","4.18.3":"0.1.7","4.19.0":"0.1.7","4.19.1":"0.1.7","4.19.2":"0.1.7","4.20.0":"0.1.10","4.21.0":"0.1.10","4.21.1":"0.1.10","4.21.2":"0.1.12","4.22.0":"~0.1.12","4.22.1":"~0.1.12","4.22.2":"~0.1.12","4.22.3":"~0.1.13","5.0.0":null}},{"pkg":"qs","installed":"6.5.2","target":"6.16.0","releases":["6.0.0","6.0.1","6.0.2","6.0.3","6.0.4","6.0.6","6.1.0","6.1.1","6.1.2","6.1.4","6.2.0","6.2.1","6.2.2","6.2.3","6.2.4","6.2.6","6.3.0","6.3.1","6.3.2","6.3.3","6.3.5","6.4.0","6.4.1","6.4.3","6.5.0","6.5.1","6.5.2","6.5.3","6.5.5","6.6.0","6.6.1","6.6.3","6.7.0","6.7.1","6.7.2","6.7.3","6.7.5","6.8.0","6.8.1","6.8.2","6.8.3","6.8.5","6.9.0","6.9.1","6.9.2","6.9.3","6.9.4","6.9.5","6.9.6","6.9.7","6.9.9","6.10.0","6.10.1","6.10.2","6.10.3","6.10.4","6.10.7","6.11.0","6.11.1","6.11.2","6.11.4","6.12.0","6.12.1","6.12.2","6.12.3","6.12.5","6.13.0","6.13.1","6.13.3","6.14.0","6.14.1","6.14.2","6.15.0","6.15.1","6.15.2","6.15.3","6.16.0","6.16.1"],"advisories":[{"id":"GHSA-4mjr-xmp4-gh2g","ranges":[{"introduced":"2.2.5","fixed":"6.16.0"}]},{"id":"GHSA-6rw7-vpxm-498p","ranges":[{"introduced":"0","fixed":"6.14.1"}]},{"id":"GHSA-f9cm-p3w6-xvr3","ranges":[{"introduced":"0","fixed":"1.0.0"}]},{"id":"GHSA-gqgv-6jq5-jjj9","ranges":[{"introduced":"0","fixed":"6.0.4"},{"introduced":"6.1.0","fixed":"6.1.2"},{"introduced":"6.2.0","fixed":"6.2.3"},{"introduced":"6.3.0","fixed":"6.3.2"}]},{"id":"GHSA-hrpp-h998-j3pp","ranges":[{"introduced":"6.10.0","fixed":"6.10.3"},{"introduced":"6.9.0","fixed":"6.9.7"},{"introduced":"6.8.0","fixed":"6.8.3"},{"introduced":"6.7.0","fixed":"6.7.3"},{"introduced":"6.6.0","fixed":"6.6.1"},{"introduced":"6.5.0","fixed":"6.5.3"},{"introduced":"6.4.0","fixed":"6.4.1"},{"introduced":"6.3.0","fixed":"6.3.3"},{"introduced":"0","fixed":"6.2.4"}]},{"id":"GHSA-jjv7-qpx3-h62q","ranges":[{"introduced":"0","fixed":"1.0.0"}]},{"id":"GHSA-q8mj-m7cp-5q26","ranges":[{"introduced":"6.11.1","fixed":"6.15.2"}]},{"id":"GHSA-w7fw-mjwx-w883","ranges":[{"introduced":"6.7.0","fixed":"6.14.2"}]},{"id":"GHSA-x5fp-wj9c-mxmx","ranges":[{"introduced":"6.14.2","fixed":"6.16.0"}]}],"requires":{"4.16.4":"6.5.2","4.17.0":"6.7.0","4.17.1":"6.7.0","4.17.2":"6.9.6","4.17.3":"6.9.7","4.18.0":"6.10.3","4.18.1":"6.10.3","4.18.2":"6.11.0","4.18.3":"6.11.0","4.19.0":"6.11.0","4.19.1":"6.11.0","4.19.2":"6.11.0","4.20.0":"6.11.0","4.21.0":"6.13.0","4.21.1":"6.13.0","4.21.2":"6.13.0","4.22.0":"~6.14.0","4.22.1":"~6.14.0","4.22.2":"~6.15.1","4.22.3":"~6.16.0","5.0.0":"6.13.0"}}],"expect":{"parent_target":"4.22.3","rows":[{"pkg":"path-to-regexp","outcome":"release","smallest":"4.22.0","to":"4.22.3","breaking":false,"requirement":"~0.1.13","peer":false,"resolves_to":"0.1.13","command":"npm install \"express@^4.22.3\"","refresh":null},{"pkg":"qs","outcome":"release","smallest":"4.22.3","to":"4.22.3","breaking":false,"requirement":"~6.16.0","peer":false,"resolves_to":"6.16.1","command":"npm install \"express@^4.22.3\"","refresh":null}]}} +{"id":"pr-001","rule":"A parent with its own vulnerable row moves once: its row target is raised to the highest release a transitive row needs from it (qs needs express 4.22.3), and a row whose smallest admitting release is lower (path-to-regexp: 4.22.0) is lifted to that target when it still admits a clean copy there. One release, one command, across rows (audit H1, rules a, b, e); express is declared ^4.13.4, which already admits 4.22.3, so the command is the in-range refresh `npm update express` (fix_command fc-054), not an install that rewrites the declaration (E8 follow-up, 2026-10-18)","source":"OWASP/NodeGoat package-lock.json (express 4.16.4 declared ^4.13.4, path-to-regexp 0.1.7, qs 6.5.2); registry.npmjs.org abbreviated documents (Accept: application/vnd.npm.install-v1+json) and OSV.dev /v1/query, read 2026-10-18; the same answer as @4da/mcp-server fix/planner-advice-625 1840dab upgrade_planner on NodeGoat (express 4.22.3 on the express, path-to-regexp and qs rows)","eco":"npm","tool":"npm","parent":{"name":"express","installed":"4.16.4","declared":"^4.13.4","lever":"direct","latest":"5.3.0","deprecated":false,"releases":["4.16.4","4.17.0","4.17.1","4.17.2","4.17.3","4.18.0","4.18.1","4.18.2","4.18.3","4.19.0","4.19.1","4.19.2","4.20.0","4.21.0","4.21.1","4.21.2","4.22.0","4.22.1","4.22.2","4.22.3","5.0.0"],"advisories":[{"id":"GHSA-cm5g-3pgc-8rg4","ranges":[{"introduced":"0","fixed":"4.0.0-rc1"}]},{"id":"GHSA-gpvr-g6gh-9mc2","ranges":[{"introduced":"0","fixed":"3.11.0"},{"introduced":"4.0.0","fixed":"4.5.0"}]},{"id":"GHSA-jj78-5fmv-mv28","ranges":[{"introduced":"3.4.5","fixed":"4.0.0-rc1"}]},{"id":"GHSA-qw6h-vgh9-j6wx","ranges":[{"introduced":"0","fixed":"4.20.0"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0"}]},{"id":"GHSA-rv95-896h-c2vc","ranges":[{"introduced":"0","fixed":"4.19.2"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0-beta.3"}]}],"own_target":"4.20.0"},"rows":[{"pkg":"path-to-regexp","installed":"0.1.7","target":"0.1.13","releases":["0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.1.5","0.1.6","0.1.7","0.1.8","0.1.9","0.1.10","0.1.11","0.1.12","0.1.13","8.0.0","8.1.0","8.2.0","8.3.0","8.4.0","8.4.1","8.4.2"],"advisories":[{"id":"GHSA-27v5-c462-wpq7","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-37ch-88jc-xwx2","ranges":[{"introduced":"0","fixed":"0.1.13"}]},{"id":"GHSA-9wv6-86v2-598j","ranges":[{"introduced":"0.2.0","fixed":"1.9.0"},{"introduced":"0","fixed":"0.1.10"},{"introduced":"7.0.0","fixed":"8.0.0"},{"introduced":"2.0.0","fixed":"3.3.0"},{"introduced":"4.0.0","fixed":"6.3.0"}]},{"id":"GHSA-j3q9-mxjg-w52f","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-rhx6-c78j-4q9w","ranges":[{"introduced":"0","fixed":"0.1.12"}]}],"requires":{"4.16.4":"0.1.7","4.17.0":"0.1.7","4.17.1":"0.1.7","4.17.2":"0.1.7","4.17.3":"0.1.7","4.18.0":"0.1.7","4.18.1":"0.1.7","4.18.2":"0.1.7","4.18.3":"0.1.7","4.19.0":"0.1.7","4.19.1":"0.1.7","4.19.2":"0.1.7","4.20.0":"0.1.10","4.21.0":"0.1.10","4.21.1":"0.1.10","4.21.2":"0.1.12","4.22.0":"~0.1.12","4.22.1":"~0.1.12","4.22.2":"~0.1.12","4.22.3":"~0.1.13","5.0.0":null}},{"pkg":"qs","installed":"6.5.2","target":"6.16.0","releases":["6.0.0","6.0.1","6.0.2","6.0.3","6.0.4","6.0.6","6.1.0","6.1.1","6.1.2","6.1.4","6.2.0","6.2.1","6.2.2","6.2.3","6.2.4","6.2.6","6.3.0","6.3.1","6.3.2","6.3.3","6.3.5","6.4.0","6.4.1","6.4.3","6.5.0","6.5.1","6.5.2","6.5.3","6.5.5","6.6.0","6.6.1","6.6.3","6.7.0","6.7.1","6.7.2","6.7.3","6.7.5","6.8.0","6.8.1","6.8.2","6.8.3","6.8.5","6.9.0","6.9.1","6.9.2","6.9.3","6.9.4","6.9.5","6.9.6","6.9.7","6.9.9","6.10.0","6.10.1","6.10.2","6.10.3","6.10.4","6.10.7","6.11.0","6.11.1","6.11.2","6.11.4","6.12.0","6.12.1","6.12.2","6.12.3","6.12.5","6.13.0","6.13.1","6.13.3","6.14.0","6.14.1","6.14.2","6.15.0","6.15.1","6.15.2","6.15.3","6.16.0","6.16.1"],"advisories":[{"id":"GHSA-4mjr-xmp4-gh2g","ranges":[{"introduced":"2.2.5","fixed":"6.16.0"}]},{"id":"GHSA-6rw7-vpxm-498p","ranges":[{"introduced":"0","fixed":"6.14.1"}]},{"id":"GHSA-f9cm-p3w6-xvr3","ranges":[{"introduced":"0","fixed":"1.0.0"}]},{"id":"GHSA-gqgv-6jq5-jjj9","ranges":[{"introduced":"0","fixed":"6.0.4"},{"introduced":"6.1.0","fixed":"6.1.2"},{"introduced":"6.2.0","fixed":"6.2.3"},{"introduced":"6.3.0","fixed":"6.3.2"}]},{"id":"GHSA-hrpp-h998-j3pp","ranges":[{"introduced":"6.10.0","fixed":"6.10.3"},{"introduced":"6.9.0","fixed":"6.9.7"},{"introduced":"6.8.0","fixed":"6.8.3"},{"introduced":"6.7.0","fixed":"6.7.3"},{"introduced":"6.6.0","fixed":"6.6.1"},{"introduced":"6.5.0","fixed":"6.5.3"},{"introduced":"6.4.0","fixed":"6.4.1"},{"introduced":"6.3.0","fixed":"6.3.3"},{"introduced":"0","fixed":"6.2.4"}]},{"id":"GHSA-jjv7-qpx3-h62q","ranges":[{"introduced":"0","fixed":"1.0.0"}]},{"id":"GHSA-q8mj-m7cp-5q26","ranges":[{"introduced":"6.11.1","fixed":"6.15.2"}]},{"id":"GHSA-w7fw-mjwx-w883","ranges":[{"introduced":"6.7.0","fixed":"6.14.2"}]},{"id":"GHSA-x5fp-wj9c-mxmx","ranges":[{"introduced":"6.14.2","fixed":"6.16.0"}]}],"requires":{"4.16.4":"6.5.2","4.17.0":"6.7.0","4.17.1":"6.7.0","4.17.2":"6.9.6","4.17.3":"6.9.7","4.18.0":"6.10.3","4.18.1":"6.10.3","4.18.2":"6.11.0","4.18.3":"6.11.0","4.19.0":"6.11.0","4.19.1":"6.11.0","4.19.2":"6.11.0","4.20.0":"6.11.0","4.21.0":"6.13.0","4.21.1":"6.13.0","4.21.2":"6.13.0","4.22.0":"~6.14.0","4.22.1":"~6.14.0","4.22.2":"~6.15.1","4.22.3":"~6.16.0","5.0.0":"6.13.0"}}],"expect":{"parent_target":"4.22.3","rows":[{"pkg":"path-to-regexp","outcome":"release","smallest":"4.22.0","to":"4.22.3","breaking":false,"requirement":"~0.1.13","peer":false,"resolves_to":"0.1.13","command":"npm update express","refresh":null},{"pkg":"qs","outcome":"release","smallest":"4.22.3","to":"4.22.3","breaking":false,"requirement":"~6.16.0","peer":false,"resolves_to":"6.16.1","command":"npm update express","refresh":null}]}} {"id":"pr-002","rule":"The smallest parent release, above the installed one and clear of the parent's own advisories, whose own requirement resolves to a clean copy at or above the target (express 4.20.0..4.21.2 pin path-to-regexp 0.1.10 / 0.1.12; 4.22.0 requires ~0.1.12, which resolves to 0.1.13). The parent's own row (4.20.0, its own advisories' fix, still pins a vulnerable path-to-regexp) is raised to that release. The command keeps the declared exact pin (audit H1, rules a, b, e)","source":"registry.npmjs.org abbreviated documents (Accept: application/vnd.npm.install-v1+json) and OSV.dev /v1/query, read 2026-10-18; express 4.16.4 as in OWASP/NodeGoat, declared exactly; the same answer as @4da/mcp-server fix/planner-advice-625 1840dab upgrade_planner on that project (express row 4.22.0, \"4.22.0, not 4.20.0\")","eco":"npm","tool":"npm","parent":{"name":"express","installed":"4.16.4","declared":"4.16.4","lever":"direct","latest":"5.3.0","deprecated":false,"releases":["4.16.4","4.17.0","4.17.1","4.17.2","4.17.3","4.18.0","4.18.1","4.18.2","4.18.3","4.19.0","4.19.1","4.19.2","4.20.0","4.21.0","4.21.1","4.21.2","4.22.0","4.22.1","4.22.2","4.22.3","5.0.0"],"advisories":[{"id":"GHSA-cm5g-3pgc-8rg4","ranges":[{"introduced":"0","fixed":"4.0.0-rc1"}]},{"id":"GHSA-gpvr-g6gh-9mc2","ranges":[{"introduced":"0","fixed":"3.11.0"},{"introduced":"4.0.0","fixed":"4.5.0"}]},{"id":"GHSA-jj78-5fmv-mv28","ranges":[{"introduced":"3.4.5","fixed":"4.0.0-rc1"}]},{"id":"GHSA-qw6h-vgh9-j6wx","ranges":[{"introduced":"0","fixed":"4.20.0"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0"}]},{"id":"GHSA-rv95-896h-c2vc","ranges":[{"introduced":"0","fixed":"4.19.2"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0-beta.3"}]}],"own_target":"4.20.0"},"rows":[{"pkg":"path-to-regexp","installed":"0.1.7","target":"0.1.13","releases":["0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.1.5","0.1.6","0.1.7","0.1.8","0.1.9","0.1.10","0.1.11","0.1.12","0.1.13","8.0.0","8.1.0","8.2.0","8.3.0","8.4.0","8.4.1","8.4.2"],"advisories":[{"id":"GHSA-27v5-c462-wpq7","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-37ch-88jc-xwx2","ranges":[{"introduced":"0","fixed":"0.1.13"}]},{"id":"GHSA-9wv6-86v2-598j","ranges":[{"introduced":"0.2.0","fixed":"1.9.0"},{"introduced":"0","fixed":"0.1.10"},{"introduced":"7.0.0","fixed":"8.0.0"},{"introduced":"2.0.0","fixed":"3.3.0"},{"introduced":"4.0.0","fixed":"6.3.0"}]},{"id":"GHSA-j3q9-mxjg-w52f","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-rhx6-c78j-4q9w","ranges":[{"introduced":"0","fixed":"0.1.12"}]}],"requires":{"4.16.4":"0.1.7","4.17.0":"0.1.7","4.17.1":"0.1.7","4.17.2":"0.1.7","4.17.3":"0.1.7","4.18.0":"0.1.7","4.18.1":"0.1.7","4.18.2":"0.1.7","4.18.3":"0.1.7","4.19.0":"0.1.7","4.19.1":"0.1.7","4.19.2":"0.1.7","4.20.0":"0.1.10","4.21.0":"0.1.10","4.21.1":"0.1.10","4.21.2":"0.1.12","4.22.0":"~0.1.12","4.22.1":"~0.1.12","4.22.2":"~0.1.12","4.22.3":"~0.1.13","5.0.0":null}}],"expect":{"parent_target":"4.22.0","rows":[{"pkg":"path-to-regexp","outcome":"release","smallest":"4.22.0","to":"4.22.0","breaking":false,"requirement":"~0.1.12","peer":false,"resolves_to":"0.1.13","command":"npm install express@4.22.0 --save-exact","refresh":null}]}} {"id":"pr-003","rule":"When the new parent release's requirement still admits the installed vulnerable copy, an install keeps that copy, so the advice names the refresh that follows the install (audit H1, rule c)","source":"registry.npmjs.org abbreviated documents (Accept: application/vnd.npm.install-v1+json) and OSV.dev /v1/query, read 2026-10-18; npm 10.9.2, 2026-10-17: `npm install express@4.22.0` from express 4.21.2 kept path-to-regexp 0.1.12 under ~0.1.12, and `npm update path-to-regexp` then moved it to 0.1.13","eco":"npm","tool":"npm","parent":{"name":"express","installed":"4.21.2","declared":"4.21.2","lever":"direct","latest":"5.3.0","deprecated":false,"releases":["4.21.2","4.22.0","4.22.1","4.22.2","4.22.3","5.0.0"],"advisories":[{"id":"GHSA-cm5g-3pgc-8rg4","ranges":[{"introduced":"0","fixed":"4.0.0-rc1"}]},{"id":"GHSA-gpvr-g6gh-9mc2","ranges":[{"introduced":"0","fixed":"3.11.0"},{"introduced":"4.0.0","fixed":"4.5.0"}]},{"id":"GHSA-jj78-5fmv-mv28","ranges":[{"introduced":"3.4.5","fixed":"4.0.0-rc1"}]},{"id":"GHSA-qw6h-vgh9-j6wx","ranges":[{"introduced":"0","fixed":"4.20.0"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0"}]},{"id":"GHSA-rv95-896h-c2vc","ranges":[{"introduced":"0","fixed":"4.19.2"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0-beta.3"}]}],"own_target":null},"rows":[{"pkg":"path-to-regexp","installed":"0.1.12","target":"0.1.13","releases":["0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.1.5","0.1.6","0.1.7","0.1.8","0.1.9","0.1.10","0.1.11","0.1.12","0.1.13","8.0.0","8.1.0","8.2.0","8.3.0","8.4.0","8.4.1","8.4.2"],"advisories":[{"id":"GHSA-27v5-c462-wpq7","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-37ch-88jc-xwx2","ranges":[{"introduced":"0","fixed":"0.1.13"}]},{"id":"GHSA-9wv6-86v2-598j","ranges":[{"introduced":"0.2.0","fixed":"1.9.0"},{"introduced":"0","fixed":"0.1.10"},{"introduced":"7.0.0","fixed":"8.0.0"},{"introduced":"2.0.0","fixed":"3.3.0"},{"introduced":"4.0.0","fixed":"6.3.0"}]},{"id":"GHSA-j3q9-mxjg-w52f","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-rhx6-c78j-4q9w","ranges":[{"introduced":"0","fixed":"0.1.12"}]}],"requires":{"4.21.2":"0.1.12","4.22.0":"~0.1.12","4.22.1":"~0.1.12","4.22.2":"~0.1.12","4.22.3":"~0.1.13","5.0.0":null}}],"expect":{"parent_target":null,"rows":[{"pkg":"path-to-regexp","outcome":"release","smallest":"4.22.0","to":"4.22.0","breaking":false,"requirement":"~0.1.12","peer":false,"resolves_to":"0.1.13","command":"npm install express@4.22.0 --save-exact","refresh":"npm update path-to-regexp"}]}} {"id":"pr-004","rule":"A parent move that crosses a major version is flagged breaking: mongodb-core 3.0.0..3.1.1 still require bson ~1.0.4 (every 1.0.x is deprecated, none clean), 3.1.2 requires ^1.1.0 (audit H1, rules a, f)","source":"registry.npmjs.org abbreviated documents (Accept: application/vnd.npm.install-v1+json) and OSV.dev /v1/query, read 2026-10-18; a project declaring mongodb-core 2.1.20 exactly, with bson 1.0.9 as in OWASP/NodeGoat","eco":"npm","tool":"npm","parent":{"name":"mongodb-core","installed":"2.1.20","declared":"2.1.20","lever":"direct","latest":"3.2.7","deprecated":false,"releases":["2.1.20","3.0.0","3.0.1","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.0.9","3.0.11","3.1.0","3.1.1","3.1.2","3.1.3","3.1.4","3.1.5","3.1.6","3.1.7","3.1.8","3.1.9","3.1.10","3.1.11","3.2.0","3.2.1","3.2.2","3.2.3","3.2.4","3.2.5","3.2.6","3.2.7"],"advisories":[],"own_target":null},"rows":[{"pkg":"bson","installed":"1.0.9","target":"1.1.4","releases":["1.1.4","1.1.5","1.1.6"],"advisories":[{"id":"GHSA-4jwp-vfvf-657p","ranges":[{"introduced":"0","fixed":"1.1.4"}]},{"id":"GHSA-8462-q7x7-g2x4","ranges":[{"introduced":"0.5.0","fixed":"1.0.5"}]},{"id":"GHSA-v8w9-2789-6hhr","ranges":[{"introduced":"0","fixed":"1.1.4"}]}],"requires":{"2.1.20":"~1.0.4","3.0.0":"~1.0.4","3.0.1":"~1.0.4","3.0.2":"~1.0.4","3.0.3":"~1.0.4","3.0.4":"~1.0.4","3.0.5":"~1.0.4","3.0.6":"~1.0.4","3.0.7":"~1.0.4","3.0.8":"~1.0.4","3.0.9":"~1.0.4","3.0.11":"~1.0.4","3.1.0":"~1.0.4","3.1.1":"~1.0.4","3.1.2":"^1.1.0","3.1.3":"^1.1.0","3.1.4":"^1.1.0","3.1.5":"^1.1.0","3.1.6":"^1.1.0","3.1.7":"^1.1.0","3.1.8":"^1.1.0","3.1.9":"^1.1.0","3.1.10":"^1.1.0","3.1.11":"^1.1.0","3.2.0":"^1.1.1","3.2.1":"^1.1.1","3.2.2":"^1.1.1","3.2.3":"^1.1.1","3.2.4":"^1.1.1","3.2.5":"^1.1.1","3.2.6":"^1.1.1","3.2.7":"^1.1.1"}}],"expect":{"parent_target":null,"rows":[{"pkg":"bson","outcome":"release","smallest":"3.1.2","to":"3.1.2","breaking":true,"requirement":"^1.1.0","peer":false,"resolves_to":"1.1.6","command":"npm install mongodb-core@3.1.2 --save-exact","refresh":null}]}}