From 41785c6a39c1b8c93922d7203a3659926bc190ba Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Thu, 16 Oct 2025 13:47:25 -0600 Subject: [PATCH] fix: recursive transitive closure analysis in npm lockfile parser --- .../fixtures/issue-10985.json | 79 +++++++++++++++++++ crates/turborepo-lockfiles/src/lib.rs | 19 ++++- crates/turborepo-lockfiles/src/npm.rs | 61 ++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 crates/turborepo-lockfiles/fixtures/issue-10985.json diff --git a/crates/turborepo-lockfiles/fixtures/issue-10985.json b/crates/turborepo-lockfiles/fixtures/issue-10985.json new file mode 100644 index 0000000000000..75b1cd33dc463 --- /dev/null +++ b/crates/turborepo-lockfiles/fixtures/issue-10985.json @@ -0,0 +1,79 @@ +{ + "name": "monorepo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "monorepo", + "version": "1.0.0", + "workspaces": ["apps/*", "packages/*"] + }, + "node_modules/next": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.5.tgz", + "integrity": "sha512-test" + }, + "node_modules/next-15": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/next/-/next-15.0.0.tgz", + "integrity": "sha512-test" + }, + "node_modules/@repo/components": { + "resolved": "packages/components", + "link": true + }, + "apps/app-one": { + "name": "app-one", + "version": "1.0.0", + "dependencies": { + "@repo/components": "*", + "next": "^14.0.0" + } + }, + "apps/app-one/node_modules/next": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.5.tgz", + "integrity": "sha512-test" + }, + "apps/app-two": { + "name": "app-two", + "version": "1.0.0", + "dependencies": { + "@repo/components": "*", + "next": "^15.0.0" + } + }, + "apps/app-two/node_modules/next": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/next/-/next-15.0.0.tgz", + "integrity": "sha512-test" + }, + "packages/components": { + "name": "@repo/components", + "version": "1.0.0", + "peerDependencies": { + "next": "^14 || ^15" + } + } + }, + "dependencies": { + "app-one": { + "version": "file:apps/app-one", + "requires": { + "@repo/components": "*", + "next": "^14.0.0" + } + }, + "app-two": { + "version": "file:apps/app-two", + "requires": { + "@repo/components": "*", + "next": "^15.0.0" + } + }, + "@repo/components": { + "version": "file:packages/components" + } + } +} diff --git a/crates/turborepo-lockfiles/src/lib.rs b/crates/turborepo-lockfiles/src/lib.rs index 0c44125a01de4..1e4d07a396c21 100644 --- a/crates/turborepo-lockfiles/src/lib.rs +++ b/crates/turborepo-lockfiles/src/lib.rs @@ -164,6 +164,23 @@ fn transitive_closure_helper( unresolved_deps: HashMap>, resolved_deps: &mut HashSet, ignore_missing_packages: bool, +) -> Result<(), Error> { + transitive_closure_helper_impl( + lockfile, + workspace_path, + unresolved_deps, + resolved_deps, + ignore_missing_packages, + ) +} + +/// Core transitive closure implementation that walks dependencies. +fn transitive_closure_helper_impl( + lockfile: &L, + workspace_path: &str, + unresolved_deps: HashMap>, + resolved_deps: &mut HashSet, + ignore_missing_packages: bool, ) -> Result<(), Error> { for (name, specifier) in unresolved_deps { let pkg = match lockfile.resolve_package(workspace_path, &name, specifier.as_ref()) { @@ -187,7 +204,7 @@ fn transitive_closure_helper( if let Some(deps) = all_deps { // we've already found one unresolved dependency, so we can't ignore its set of // dependencies. - transitive_closure_helper( + transitive_closure_helper_impl( lockfile, workspace_path, deps, diff --git a/crates/turborepo-lockfiles/src/npm.rs b/crates/turborepo-lockfiles/src/npm.rs index dc297e6fcc70d..6f057a8cc18ae 100644 --- a/crates/turborepo-lockfiles/src/npm.rs +++ b/crates/turborepo-lockfiles/src/npm.rs @@ -463,6 +463,67 @@ mod test { Ok(()) } + #[test] + fn test_issue_10985_peer_dependencies_with_different_versions() -> Result<(), Error> { + let lockfile = NpmLockfile::load(include_bytes!("../fixtures/issue-10985.json"))?; + + let closures = crate::all_transitive_closures( + &lockfile, + vec![ + ( + "apps/app-one".into(), + vec![ + ("@repo/components".into(), "*".into()), + ("next".into(), "^14.0.0".into()), + ] + .into_iter() + .collect(), + ), + ( + "apps/app-two".into(), + vec![ + ("@repo/components".into(), "*".into()), + ("next".into(), "^15.0.0".into()), + ] + .into_iter() + .collect(), + ), + ] + .into_iter() + .collect(), + false, + )?; + + let app_one_deps = closures.get("apps/app-one").unwrap(); + let app_two_deps = closures.get("apps/app-two").unwrap(); + + assert!(app_one_deps.contains(&Package { + key: "apps/app-one/node_modules/next".into(), + version: "14.2.5".into() + })); + assert!(app_two_deps.contains(&Package { + key: "apps/app-two/node_modules/next".into(), + version: "15.0.0".into() + })); + + assert!( + !app_one_deps.contains(&Package { + key: "apps/app-two/node_modules/next".into(), + version: "15.0.0".into() + }), + "app-one should not include next@15.0.0" + ); + assert!( + !app_two_deps.contains(&Package { + key: "apps/app-one/node_modules/next".into(), + version: "14.2.5".into() + }), + "app-two should not include next@14.2.5" + ); + + Ok(()) + } + #[test] fn test_turbo_version() -> Result<(), Error> { let lockfile = NpmLockfile::load(include_bytes!("../fixtures/npm-lock.json"))?;