From 9fd0c7e350b30d6ef1fbe120dda065f7d1a5599a Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 18 Oct 2026 22:14:46 +1000 Subject: [PATCH 1/4] fix(direct-upgrade): a declaration that admits the fix gets the in-range refresh, not a bump (E8 follow-up) Live 2026-10-18 (navcal): sharp, next and vite were told to `npm install "x@^v"` though their `^` range already admitted the fix target. When the declared range admits the target, the minimal correct advice is the tool's in-range lockfile refresh, which leaves the manifest as it is. Each form was run on 2026-10-18 against a project declaring `ms ^2.1.1` locked at 2.1.1; each moved the lock to 2.1.3 with package.json unchanged: - npm 10.9.2: `npm update ms` - pnpm 10.34.6: `pnpm update ms --no-save` (plain `pnpm update ms` rewrote the range to `^2.1.3`); also inside a workspace member, where only that member's importer moved - yarn 1.22.22: `yarn upgrade ms`; yarn 4.9.1: `yarn up -R ms` - cargo 1.97: `cargo update -p itoa --precise 1.0.11` on `itoa = "1.0.5"`, Cargo.toml unchanged DirectFix gains Refresh. The plan reads "Fixable now with a lockfile refresh (the declared range already admits the fix)" when every fix is one; in a mixed row a refresh says what it is. The Brief reads "bump x to >= v: within its declared range, run `npm update x`". Any readable range qualifies (a `>=` range carries no operator but admits the target). Yarn workspace members, Cargo workspace/git/path/registry entries keep their existing declines. Vectors: fc-054..fc-059 (refresh: true). fc-029..035 and fc-037..041 had in-range targets; they move outside the declared range (sources say so) to keep pinning the operator-keeping install. fc-036 keeps its decline purpose with `1.x` -> 2.0.0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015KSXWNneouUjTiVV3Rc3FH --- src-tauri/src/brief_facts_build.rs | 5 +- src-tauri/src/brief_facts_build_tests.rs | 8 +- .../src/evidence/upgrade_parent_tests.rs | 50 +++++++- src-tauri/src/evidence/upgrade_plan.rs | 11 +- src-tauri/src/osv/direct_upgrade.rs | 91 +++++++++++++- src-tauri/src/osv/direct_upgrade_tests.rs | 113 +++++++++++++++++- src-tauri/src/osv/vectors_tests.rs | 12 +- src-tauri/tests/conformance/vectors/README.md | 1 + .../conformance/vectors/fix_command.jsonl | 30 +++-- 9 files changed, 279 insertions(+), 42 deletions(-) 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..5baf267a8 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( @@ -120,12 +120,12 @@ fn seeded_project(tag: &str) -> Option<(tempfile::TempDir, String, crate::db::Da } std::fs::write( dir.path().join("package.json"), - r#"{"name": "app", "dependencies": {"minimist": "~1.2.5"}}"#, + 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"}}}"#, + r#"{"lockfileVersion": 3, "packages": {"": {"dependencies": {"minimist": "1.2.5"}}, "node_modules/minimist": {"version": "1.2.5"}}}"#, ) .unwrap(); let db = crate::test_utils::test_db(); @@ -219,7 +219,7 @@ fn the_brief_and_the_plan_name_the_same_command() { .iter() .find(|i| i.title.contains("minimist")) .expect("a minimist row"); - let command = "`npm install \"minimist@~1.2.6\"`"; + let command = "`npm install minimist@1.2.6 --save-exact`"; assert!( row.explanation.contains(command), "plan: {}", diff --git a/src-tauri/src/evidence/upgrade_parent_tests.rs b/src-tauri/src/evidence/upgrade_parent_tests.rs index 0157f4508..b1832ab59 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_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"}}"#, @@ -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..0381acd52 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) { + "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..d801b747e 100644 --- a/src-tauri/src/osv/direct_upgrade.rs +++ b/src-tauri/src/osv/direct_upgrade.rs @@ -35,8 +35,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 +50,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 @@ -77,10 +112,34 @@ pub fn direct_fix( if requirement_admits(&declared, installed) != Some(true) { return None; } + // The declaration already admits the fix: refresh, never rewrite + // it. Any readable range qualifies, `>=` and `||` included. + if 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) } "crates.io" => match cargo_declarations(&read("Cargo.toml")?, package).as_slice() { - [only] if cargo_admits(only, installed) => cargo_direct(only, package, target), + [only] if cargo_admits(only, installed) => { + 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) + } + } _ => None, }, "Go" => { @@ -483,19 +542,39 @@ pub fn direct_fixes( out } -/// ": `npm install ...`" for one project, or each fix with the project it runs in. +/// Every named fix is an in-range lockfile refresh: the row is worded as a +/// refresh, not a bump. +pub fn all_refresh(fixes: &[(String, String, DirectFix)]) -> bool { + !fixes.is_empty() + && 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 = !all_refresh(fixes); 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..e75185839 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,99 @@ 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 has no operator a command can carry, but it admits the + // target: the refresh is named. + 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"), + Some(DirectFix::Refresh("npm update ms".to_string())) + ); +} + +/// The row wording follows: all refreshes read as a refresh; in a mixed row +/// the refresh says what it is. +#[test] +fn the_note_words_a_refresh_as_one() { + 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))); + 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/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..a138f06b5 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), and a `>=` range (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..74b515a74 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":"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":"^1.2.5","installed":"1.2.5","target":"2.0.1","workspace_root":false,"command":"npm install \"minimist@^2.0.1\""} +{"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"npm","eco":"npm","pkg":"ms","declared":"~2.1.1","installed":"2.1.1","target":"2.2.0","workspace_root":false,"command":"npm install \"ms@~2.2.0\""} +{"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","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":"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"pnpm","eco":"npm","pkg":"ms","declared":"~2.1.1","installed":"2.1.1","target":"2.2.0","workspace_root":false,"command":"pnpm up \"ms@~2.2.0\""} {"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":"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"yarn1","eco":"npm","pkg":"ms","declared":"~2.1.1","installed":"2.1.1","target":"2.2.0","workspace_root":false,"command":"yarn upgrade \"ms@~2.2.0\""} +{"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"yarn-berry","eco":"npm","pkg":"minimist","declared":"^1.2.5","installed":"1.2.5","target":"2.0.1","workspace_root":false,"command":"yarn up \"minimist@^2.0.1\""} +{"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.x` has no operator a command can carry and does not admit 2.0.0, so the plan keeps its plain 'bump it where it is declared'. (Until 2026-10-18 this line was `>=1.2.0`; a range that admits the target now gets the in-range refresh, fc-059.)","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":"1.x","installed":"1.2.5","target":"2.0.0","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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"serde","manifest":"[dependencies]\nserde = { version = \"1.0.100\", features = [\"derive\"] }\n","installed":"1.0.100","target":"2.0.0","workspace_root":false,"command":"cargo add serde@2.0.0"} +{"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"ryu","manifest":"[dev-dependencies]\nryu = \"~1.0.5\"\n","installed":"1.0.5","target":"1.1.0","workspace_root":false,"command":"cargo add \"ryu@~1.1.0\" --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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"cc","manifest":"[build-dependencies]\ncc = \"1.0.50\"\n","installed":"1.0.50","target":"2.0.0","workspace_root":false,"command":"cargo add cc@2.0.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":"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; target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","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.4.0","workspace_root":false,"command":"cargo add winapi@0.4.0 --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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"itoa","manifest":"[dependencies]\nmyitoa = { package = \"itoa\", version = \"1.0.1\" }\n","installed":"1.0.1","target":"2.0.0","workspace_root":false,"command":"cargo add itoa@2.0.0 --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,9 @@ {"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 --precise 1.0.11` 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 — npm: a `>=` range carries no operator, but it admits the target, so the refresh is named","source":"Rule over fc-054's verified command: `>=1.2.0` admits 1.2.8","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":">=1.2.0","installed":"1.2.5","target":"1.2.8","command":"npm update minimist","workspace_root":false,"refresh":true} From 6fe909f62038a0cf0bb204d6297dd67d296ef0aa Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 18 Oct 2026 22:43:25 +1000 Subject: [PATCH 2/4] test(vectors): pr-001 is the in-range refresh; npm workspace member refresh (fc-060) pr-001: express is declared ^4.13.4, which already admits 4.22.3, so the lever's command is `npm update express` (fc-054's rule), not an install that rewrites the declaration. fc-060: an npm workspace member's in-range refresh runs in the member's directory; npm 10.9.2 moved the member's copy both hoisted and nested, declaration unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015KSXWNneouUjTiVV3Rc3FH --- src-tauri/tests/conformance/vectors/README.md | 2 +- src-tauri/tests/conformance/vectors/fix_command.jsonl | 1 + src-tauri/tests/conformance/vectors/parent_release.jsonl | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/tests/conformance/vectors/README.md b/src-tauri/tests/conformance/vectors/README.md index a138f06b5..f419a6799 100644 --- a/src-tauri/tests/conformance/vectors/README.md +++ b/src-tauri/tests/conformance/vectors/README.md @@ -245,7 +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), and a `>=` range (fc-059). +- 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), a `>=` range (fc-059), and an npm workspace member, run in the member's directory (fc-060). - 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 74b515a74..27e91302c 100644 --- a/src-tauri/tests/conformance/vectors/fix_command.jsonl +++ b/src-tauri/tests/conformance/vectors/fix_command.jsonl @@ -57,3 +57,4 @@ {"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 --precise 1.0.11` 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 — npm: a `>=` range carries no operator, but it admits the target, so the refresh is named","source":"Rule over fc-054's verified command: `>=1.2.0` admits 1.2.8","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":">=1.2.0","installed":"1.2.5","target":"1.2.8","command":"npm update minimist","workspace_root":false,"refresh":true} +{"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} 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}]}} From a8c54600b47fc4b93a9410c2037a8787823274fb Mon Sep 17 00:00:00 2001 From: antony Date: Sun, 18 Oct 2026 23:04:00 +1000 Subject: [PATCH 3/4] fix(parent-release): a lever whose range admits the release names the in-range refresh lever_command and the decide-mode vector helper matched only DirectFix::Command (the helper with a `_ => None` wildcard), so the new DirectFix::Refresh dropped out and pr-001's lever named nothing. Both are now exhaustive: a lever declared ^4.13.4 moving to 4.22.3 names `npm update express`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015KSXWNneouUjTiVV3Rc3FH --- src-tauri/src/osv/parent_release_load.rs | 7 +++++-- src-tauri/src/osv/parent_release_vectors.rs | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) 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, }) } From cf314dc24adc59154d147318374613ebb12805d7 Mon Sep 17 00:00:00 2001 From: antony Date: Mon, 19 Oct 2026 00:15:27 +1000 Subject: [PATCH 4/4] fix(direct-upgrade): review of #993: re-grounded vectors on real releases; refresh only a bounded range; refresh wording only when every direct site refreshes - B1: fc-029..035 and fc-037..041 had been moved to releases that do not exist (minimist 2.0.1, ms 2.2.0, serde 2.0.0, ...). They now use REAL targets, with the declaration chosen so the target is out of range, and each command was re-run against the real tool on 2026-10-18; each vector's source is that run (npm 10.9.2 --package-lock-only, pnpm 10.34.6, yarn 1.22.22, yarn 4.9.1, cargo 1.97). npm wrote ^8.0.7 for `npm install "@babel/traverse@^8.0.0"` (the resolved release), so fc-031 says so. fc-036 is `0.2.x` -> 1.2.8 (a rule, no run). fc-058 cites the exact command text it ran. - S3: only a range bounded below the next major (`^`, `~`, or a Cargo requirement) is refreshed: a refresh installs the NEWEST release in range, so `>=` / `||` / `*` / x-ranges could jump a major or land on a withdrawn or held release. fc-059 (`>=1.2.0`) names nothing. - S2: the refresh wording applies only when every direct site of the row got a refresh; a site whose fix could not be named keeps "bump". - S4: the Brief/plan parity test covers the exact pin (install) and the tilde (refresh) again. - S5: the npm and Cargo arms are their own functions. S6: the module doc says which ranges refresh. - fc-061: the pnpm workspace-member refresh (run 2026-10-18). The twin plan test's skip message names itself. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015KSXWNneouUjTiVV3Rc3FH --- src-tauri/src/brief_facts_build_tests.rs | 63 +++++--- .../src/evidence/upgrade_parent_tests.rs | 2 +- src-tauri/src/evidence/upgrade_plan.rs | 2 +- src-tauri/src/osv/direct_upgrade.rs | 148 ++++++++++++------ src-tauri/src/osv/direct_upgrade_tests.rs | 42 +++-- src-tauri/tests/conformance/vectors/README.md | 2 +- .../conformance/vectors/fix_command.jsonl | 29 ++-- 7 files changed, 193 insertions(+), 95 deletions(-) diff --git a/src-tauri/src/brief_facts_build_tests.rs b/src-tauri/src/brief_facts_build_tests.rs index 5baf267a8..573368060 100644 --- a/src-tauri/src/brief_facts_build_tests.rs +++ b/src-tauri/src/brief_facts_build_tests.rs @@ -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 --save-exact`"; - 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 b1832ab59..0fba7719a 100644 --- a/src-tauri/src/evidence/upgrade_parent_tests.rs +++ b/src-tauri/src/evidence/upgrade_parent_tests.rs @@ -611,7 +611,7 @@ fn a_direct_row_whose_range_admits_the_fix_names_the_refresh() { .expect("tempdir"); let project = dir.path().to_string_lossy().replace('\\', "/"); if crate::project_inclusion::is_hard_excluded(&project) { - eprintln!("SKIPPED a_direct_row_names_the_command_for_its_declaration: {project} is excluded from DB writes here"); + eprintln!("SKIPPED a_direct_row_whose_range_admits_the_fix_names_the_refresh: {project} is excluded from DB writes here"); return; } std::fs::write( diff --git a/src-tauri/src/evidence/upgrade_plan.rs b/src-tauri/src/evidence/upgrade_plan.rs index 0381acd52..f1a44150c 100644 --- a/src-tauri/src/evidence/upgrade_plan.rs +++ b/src-tauri/src/evidence/upgrade_plan.rs @@ -453,7 +453,7 @@ impl PackageGroup<'_> { .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) { + 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" diff --git a/src-tauri/src/osv/direct_upgrade.rs b/src-tauri/src/osv/direct_upgrade.rs index d801b747e..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; @@ -100,48 +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; - } - // The declaration already admits the fix: refresh, never rewrite - // it. Any readable range qualifies, `>=` and `||` included. - if 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) - } - "crates.io" => match cargo_declarations(&read("Cargo.toml")?, package).as_slice() { - [only] if cargo_admits(only, installed) => { - 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) - } - } - _ => 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) } @@ -157,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 { @@ -542,10 +579,23 @@ pub fn direct_fixes( out } -/// Every named fix is an in-range lockfile refresh: the row is worded as a -/// refresh, not a bump. -pub fn all_refresh(fixes: &[(String, String, DirectFix)]) -> bool { - !fixes.is_empty() +/// 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(_))) @@ -557,7 +607,9 @@ pub fn direct_fix_note(fixes: &[(String, String, DirectFix)], multi_project: boo if fixes.is_empty() { return String::new(); } - let mixed = !all_refresh(fixes); + let mixed = !fixes + .iter() + .all(|(_, _, f)| matches!(f, DirectFix::Refresh(_))); let shown: Vec = fixes .iter() .take(MAX_DIRECT_FIXES) diff --git a/src-tauri/src/osv/direct_upgrade_tests.rs b/src-tauri/src/osv/direct_upgrade_tests.rs index e75185839..14580ba86 100644 --- a/src-tauri/src/osv/direct_upgrade_tests.rs +++ b/src-tauri/src/osv/direct_upgrade_tests.rs @@ -441,8 +441,9 @@ fn a_declaration_that_admits_the_target_gets_a_refresh_not_a_bump() { "{lockfile}" ); } - // A `>=` range has no operator a command can carry, but it admits the - // target: the refresh is named. + // 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"), @@ -454,16 +455,29 @@ fn a_declaration_that_admits_the_target_gets_a_refresh_not_a_bump() { "{\"lockfileVersion\": 3}", ) .unwrap(); - assert_eq!( - direct_fix(dir.path(), "npm", "ms", "2.1.1", "2.1.3"), - Some(DirectFix::Refresh("npm update ms".to_string())) - ); + 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. +/// 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(), @@ -474,14 +488,24 @@ fn the_note_words_a_refresh_as_one() { "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))); + 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.contains( + "`npm update minimist` (a lockfile refresh: the declared range already admits it) in /a" + ), "{mixed}" ); assert!( diff --git a/src-tauri/tests/conformance/vectors/README.md b/src-tauri/tests/conformance/vectors/README.md index f419a6799..250a564d1 100644 --- a/src-tauri/tests/conformance/vectors/README.md +++ b/src-tauri/tests/conformance/vectors/README.md @@ -245,7 +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), a `>=` range (fc-059), and an npm workspace member, run in the member's directory (fc-060). +- 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 27e91302c..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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":"^1.2.5","installed":"1.2.5","target":"2.0.1","workspace_root":false,"command":"npm install \"minimist@^2.0.1\""} -{"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"npm","eco":"npm","pkg":"ms","declared":"~2.1.1","installed":"2.1.1","target":"2.2.0","workspace_root":false,"command":"npm install \"ms@~2.2.0\""} -{"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","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":"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"pnpm","eco":"npm","pkg":"ms","declared":"~2.1.1","installed":"2.1.1","target":"2.2.0","workspace_root":false,"command":"pnpm up \"ms@~2.2.0\""} +{"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"yarn1","eco":"npm","pkg":"ms","declared":"~2.1.1","installed":"2.1.1","target":"2.2.0","workspace_root":false,"command":"yarn upgrade \"ms@~2.2.0\""} -{"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"yarn-berry","eco":"npm","pkg":"minimist","declared":"^1.2.5","installed":"1.2.5","target":"2.0.1","workspace_root":false,"command":"yarn up \"minimist@^2.0.1\""} -{"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.x` has no operator a command can carry and does not admit 2.0.0, so the plan keeps its plain 'bump it where it is declared'. (Until 2026-10-18 this line was `>=1.2.0`; a range that admits the target now gets the in-range refresh, fc-059.)","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":"1.x","installed":"1.2.5","target":"2.0.0","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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"serde","manifest":"[dependencies]\nserde = { version = \"1.0.100\", features = [\"derive\"] }\n","installed":"1.0.100","target":"2.0.0","workspace_root":false,"command":"cargo add serde@2.0.0"} -{"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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"ryu","manifest":"[dev-dependencies]\nryu = \"~1.0.5\"\n","installed":"1.0.5","target":"1.1.0","workspace_root":false,"command":"cargo add \"ryu@~1.1.0\" --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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"cc","manifest":"[build-dependencies]\ncc = \"1.0.50\"\n","installed":"1.0.50","target":"2.0.0","workspace_root":false,"command":"cargo add cc@2.0.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":"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; target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","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.4.0","workspace_root":false,"command":"cargo add winapi@0.4.0 --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); target moved outside the declared range on 2026-10-18, because a target the range admits now gets the in-range refresh (fc-054..fc-059)","kind":"direct","tool":"cargo","eco":"crates.io","pkg":"itoa","manifest":"[dependencies]\nmyitoa = { package = \"itoa\", version = \"1.0.1\" }\n","installed":"1.0.1","target":"2.0.0","workspace_root":false,"command":"cargo add itoa@2.0.0 --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"} @@ -55,6 +55,7 @@ {"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 --precise 1.0.11` 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 — npm: a `>=` range carries no operator, but it admits the target, so the refresh is named","source":"Rule over fc-054's verified command: `>=1.2.0` admits 1.2.8","kind":"direct","tool":"npm","eco":"npm","pkg":"minimist","declared":">=1.2.0","installed":"1.2.5","target":"1.2.8","command":"npm update minimist","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}