KT-45789 Transitive npm dependencies
diff --git a/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/kotlin/org/jetbrains/kotlin/gradle/KotlinJsLibraryGradlePluginIT.kt b/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/kotlin/org/jetbrains/kotlin/gradle/KotlinJsLibraryGradlePluginIT.kt
index c1741de..4076f20 100644
--- a/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/kotlin/org/jetbrains/kotlin/gradle/KotlinJsLibraryGradlePluginIT.kt
+++ b/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/kotlin/org/jetbrains/kotlin/gradle/KotlinJsLibraryGradlePluginIT.kt
@@ -5,11 +5,14 @@
 
 package org.jetbrains.kotlin.gradle
 
+import com.google.gson.Gson
+import com.google.gson.JsonObject
 import org.gradle.util.GradleVersion
 import org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType
 import org.jetbrains.kotlin.gradle.testbase.*
 import org.junit.jupiter.api.DisplayName
 import java.util.zip.ZipFile
+import kotlin.io.path.reader
 import kotlin.test.assertNotNull
 
 // TODO: This suite is failing with deprecation error on Gradle <7.0 versions
@@ -34,6 +37,14 @@
                 assertFileInProjectExists("build/productionLibrary/js-library.js")
                 assertFileInProjectExists("build/productionLibrary/package.json")
                 assertFileInProjectExists("build/productionLibrary/main.js")
+                projectPath.resolve("build/productionLibrary/package.json").reader()
+                    .use { Gson().fromJson(it, JsonObject::class.java) }
+                    .getAsJsonObject("dependencies")
+                    ?.entrySet()?.associate { (k, v) -> k to v.asString }
+                    .let { dependencies ->
+                        assertNotNull(dependencies?.get("kotlin")) { "Direct npm dependency missing in package.json" }
+                        assertNotNull(dependencies?.get("@js-joda/core")) { "Transitive npm dependency missing in package.json" }
+                    }
             }
         }
     }
@@ -77,4 +88,4 @@
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/kotlin/org/jetbrains/kotlin/gradle/testbase/testDsl.kt b/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/kotlin/org/jetbrains/kotlin/gradle/testbase/testDsl.kt
index e8debe2..91add3f 100644
--- a/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/kotlin/org/jetbrains/kotlin/gradle/testbase/testDsl.kt
+++ b/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/kotlin/org/jetbrains/kotlin/gradle/testbase/testDsl.kt
@@ -74,12 +74,11 @@
     localRepoDir?.let { testProject.configureLocalRepository(localRepoDir) }
     if (buildJdk != null) testProject.setupNonDefaultJdk(buildJdk)
 
-    runCatching {
+    val result = runCatching {
         testProject.test()
-    }.onFailure {
-        // A convenient place to place a breakpoint to be able to inspect project output files
-        throw it
     }
+    // A convenient place to place a breakpoint to be able to inspect project output files
+    result.getOrThrow()
     return testProject
 }
 
diff --git a/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/resources/testProject/js-library-with-executable/build.gradle.kts b/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/resources/testProject/js-library-with-executable/build.gradle.kts
index f13eb65..93aa23c 100644
--- a/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/resources/testProject/js-library-with-executable/build.gradle.kts
+++ b/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/resources/testProject/js-library-with-executable/build.gradle.kts
@@ -24,4 +24,4 @@
 // We need to think about it, when we will support multiple binaries
 tasks.named("nodeProductionLibraryPrepare") {
     mustRunAfter("productionExecutableCompileSync")
-}
\ No newline at end of file
+}
diff --git a/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/resources/testProject/simple-js-library/build.gradle.kts b/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/resources/testProject/simple-js-library/build.gradle.kts
index 2a0fa5c..479fe62 100644
--- a/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/resources/testProject/simple-js-library/build.gradle.kts
+++ b/libraries/tools/kotlin-gradle-plugin-integration-tests/src/test/resources/testProject/simple-js-library/build.gradle.kts
@@ -15,4 +15,12 @@
         binaries.library()
         nodejs()
     }
-}
\ No newline at end of file
+    sourceSets {
+        main {
+            dependencies {
+                implementation("org.jetbrains.kotlinx:kotlinx-datetime:latest.release")
+                implementation(npm("kotlin", "*"))
+            }
+        }
+    }
+}
diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/GradleNodeModule.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/GradleNodeModule.kt
index b38cce8..1ed9977 100644
--- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/GradleNodeModule.kt
+++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/GradleNodeModule.kt
@@ -5,6 +5,8 @@
 
 package org.jetbrains.kotlin.gradle.targets.js.npm
 
+import com.google.gson.Gson
+import com.google.gson.JsonObject
 import java.io.File
 import java.io.Serializable
 
@@ -14,4 +16,25 @@
 data class GradleNodeModule(val name: String, val version: String, val path: File) : Serializable {
     val semver: SemVer
         get() = SemVer.from(version)
-}
\ No newline at end of file
+
+    @get:Synchronized
+    val dependencies: Set<NpmDependencyDeclaration> by lazy {
+        val pJson = path.resolve("package.json").reader().use {
+            Gson().fromJson(it, JsonObject::class.java)
+        }
+        val normal = pJson.getAsJsonObject("dependencies")
+        val peer = pJson.getAsJsonObject("peerDependencies")
+        val optional = pJson.getAsJsonObject("optionalDependencies")
+        val dev = pJson.getAsJsonObject("devDependencies")
+        mapOf(
+            NpmDependency.Scope.NORMAL to normal,
+            NpmDependency.Scope.PEER to peer,
+            NpmDependency.Scope.OPTIONAL to optional,
+            NpmDependency.Scope.DEV to dev
+        ).mapValues { (_, deps) ->
+            deps?.entrySet()?.associate { (k, v) -> k to v.asString }
+        }.mapNotNull { (scope, deps) ->
+            deps?.map { (k, v) -> NpmDependencyDeclaration(scope, k, v, false) }
+        }.flatten().toSet()
+    }
+}
diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/resolver/KotlinCompilationNpmResolver.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/resolver/KotlinCompilationNpmResolver.kt
index 567e4a0..2ae344b 100644
--- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/resolver/KotlinCompilationNpmResolver.kt
+++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/resolver/KotlinCompilationNpmResolver.kt
@@ -446,6 +446,9 @@
                         )
                     }
             }.filterNotNull()
+            val transitiveNpmDependencies = importedExternalGradleDependencies.flatMap {
+                it.dependencies
+            }.filter { it.scope != NpmDependency.Scope.DEV }
 
             val compositeDependencies = internalCompositeDependencies.flatMap { dependency ->
                 dependency.getPackages()
@@ -456,8 +459,7 @@
                             file
                         )
                     }
-            }
-                .filterNotNull()
+            }.filterNotNull()
 
             val toolsNpmDependencies = compilationResolver.rootResolver.taskRequirements
                 .getCompilationNpmRequirements(projectPath, compilationResolver.compilationDisambiguatedName)
@@ -473,7 +475,8 @@
                 )
             } else emptySet()
 
-            val allNpmDependencies = externalNpmDependencies + toolsNpmDependencies + dukatIfNecessary
+            val otherNpmDependencies = toolsNpmDependencies + dukatIfNecessary + transitiveNpmDependencies
+            val allNpmDependencies = disambiguateDependencies(externalNpmDependencies, otherNpmDependencies)
             val packageJsonHandlers = if (compilationResolver.compilation != null) {
                 compilationResolver.compilation.packageJsonHandlers
             } else {
@@ -511,6 +514,32 @@
             )
         }
 
+        private fun disambiguateDependencies(
+            direct: Collection<NpmDependencyDeclaration>,
+            others: Collection<NpmDependencyDeclaration>,
+        ): Collection<NpmDependencyDeclaration> {
+            val unique = others.groupBy(NpmDependencyDeclaration::name)
+                .filterKeys { k -> direct.none { it.name == k } }
+                .mapNotNull { (name, dependencies) ->
+                    dependencies.maxByOrNull { dep ->
+                        SemVer.from(dep.version, true)
+                    }?.also { selected ->
+                        if (dependencies.size > 1) {
+                            compilationResolver.project.logger.warn(
+                                """
+                                Transitive npm dependency version clash for compilation "${compilationResolver.compilation.name}"
+                                    Candidates:
+                                ${dependencies.joinToString("\n") { "\t\t" + it.name + "@" + it.version }}
+                                    Selected:
+                                        ${selected.name}@${selected.version}
+                                """.trimIndent()
+                            )
+                        }
+                    }
+                }
+            return direct + unique
+        }
+
         private fun CompositeDependency.getPackages(): List<File> {
             val packages = includedBuildDir.resolve(projectPackagesDir.relativeTo(rootDir))
             return packages
@@ -520,4 +549,4 @@
                 ?: emptyList()
         }
     }
-}
\ No newline at end of file
+}