diff --git a/plugins/package-managers/node/build.gradle.kts b/plugins/package-managers/node/build.gradle.kts index 9c5e7e972013e..d7218ba8b8851 100644 --- a/plugins/package-managers/node/build.gradle.kts +++ b/plugins/package-managers/node/build.gradle.kts @@ -37,6 +37,8 @@ dependencies { } api(libs.jackson.databind) + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") implementation(projects.downloader) implementation(projects.utils.ortUtils) diff --git a/plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt b/plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt index dd44c8fefd76f..cb649b4d5b4b7 100644 --- a/plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt +++ b/plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * https://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -21,8 +21,16 @@ package org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm import java.io.File +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.kotlin.logger +import org.semver4j.range.RangeList +import org.semver4j.range.RangeListFactory + import org.ossreviewtoolkit.analyzer.PackageManagerFactory import org.ossreviewtoolkit.model.ProjectAnalyzerResult import org.ossreviewtoolkit.model.config.AnalyzerConfiguration @@ -30,7 +38,6 @@ import org.ossreviewtoolkit.model.config.Excludes import org.ossreviewtoolkit.model.utils.DependencyGraphBuilder import org.ossreviewtoolkit.plugins.api.OrtPlugin import org.ossreviewtoolkit.plugins.api.PluginDescriptor -import org.ossreviewtoolkit.plugins.packagemanagers.node.ModuleInfoResolver import org.ossreviewtoolkit.plugins.packagemanagers.node.NodePackageManager import org.ossreviewtoolkit.plugins.packagemanagers.node.NodePackageManagerType import org.ossreviewtoolkit.plugins.packagemanagers.node.Scope @@ -41,8 +48,13 @@ import org.ossreviewtoolkit.utils.common.DirectoryStash import org.ossreviewtoolkit.utils.common.Os import org.ossreviewtoolkit.utils.common.nextOrNull -import org.semver4j.range.RangeList -import org.semver4j.range.RangeListFactory +// pnpm-local ModuleInfo (file is plugins/package-managers/node/src/main/kotlin/pnpm/ModuleInfo.kt) +import org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm.ModuleInfo + +// ModuleInfoResolver lives in plugins/package-managers/node/src/main/kotlin/ModuleInfoResolver.kt +import org.ossreviewtoolkit.plugins.packagemanagers.node.ModuleInfoResolver + +private val logger = LogManager.getLogger("Pnpm") internal object PnpmCommand : CommandLineTool { override fun command(workingDir: File?) = if (Os.isWindows) "pnpm.cmd" else "pnpm" @@ -52,6 +64,9 @@ internal object PnpmCommand : CommandLineTool { /** * The [PNPM package manager](https://pnpm.io/). + * + * NOTE: This file has been made conservative and defensive so it compiles and + * the analyzer does not crash when pnpm returns unexpected JSON structures. */ @OrtPlugin( id = "PNPM", @@ -65,10 +80,8 @@ class Pnpm(override val descriptor: PluginDescriptor = PnpmFactory.descriptor) : private lateinit var stash: DirectoryStash - private val moduleInfoResolver = ModuleInfoResolver.create { workingDir, moduleId -> + private val moduleInfoResolver = ModuleInfoResolver.create { workingDir: File, moduleId: String -> runCatching { - // Note that pnpm does not actually implement the "info" subcommand itself, but just forwards to npm, see - // https://github.com/pnpm/pnpm/issues/5935. val process = PnpmCommand.run(workingDir, "info", "--json", moduleId).requireSuccess() parsePackageJson(process.stdout) }.onFailure { e -> @@ -97,6 +110,13 @@ class Pnpm(override val descriptor: PluginDescriptor = PnpmFactory.descriptor) : stash.close() } + /** + * Main entry for resolving dependencies of a single definition file. + * + * Important: this implementation is defensive: if pnpm output cannot be parsed + * into module info for a scope, that scope is skipped for that project to + * avoid throwing exceptions (like NoSuchElementException). + */ override fun resolveDependencies( analysisRoot: File, definitionFile: File, @@ -108,20 +128,38 @@ class Pnpm(override val descriptor: PluginDescriptor = PnpmFactory.descriptor) : moduleInfoResolver.workingDir = workingDir val scopes = Scope.entries.filterNot { scope -> scope.isExcluded(excludes) } + // Ensure dependencies are installed (as before). installDependencies(workingDir, scopes) + // Determine workspace module directories. val workspaceModuleDirs = getWorkspaceModuleDirs(workingDir) handler.setWorkspaceModuleDirs(workspaceModuleDirs) + // For each scope, attempt to list modules. listModules is defensive and may return an empty list. val moduleInfosForScope = scopes.associateWith { scope -> listModules(workingDir, scope) } return workspaceModuleDirs.map { projectDir -> val packageJsonFile = projectDir.resolve(NodePackageManagerType.DEFINITION_FILE) val project = parseProject(packageJsonFile, analysisRoot) + // For each scope, try to find ModuleInfo. If none found, warn and skip adding dependencies for that scope. scopes.forEach { scope -> - val moduleInfo = moduleInfosForScope.getValue(scope).single { it.path == projectDir.absolutePath } - graphBuilder.addDependencies(project.id, scope.descriptor, moduleInfo.getScopeDependencies(scope)) + val candidates = moduleInfosForScope.getValue(scope) + val moduleInfo = candidates.find { File(it.path).absoluteFile == projectDir.absoluteFile } + + if (moduleInfo == null) { + logger.warn { + if (candidates.isEmpty()) { + "PNPM did not return any modules for scope $scope under $projectDir." + } else { + "PNPM returned modules for scope $scope under $projectDir, but none matched the expected path. " + + "Available paths: ${candidates.map { it.path }}" + } + } + // Skip adding dependencies for this scope to avoid exceptions. + } else { + graphBuilder.addDependencies(project.id, scope.descriptor, moduleInfo.getScopeDependencies(scope)) + } } ProjectAnalyzerResult( @@ -131,24 +169,140 @@ class Pnpm(override val descriptor: PluginDescriptor = PnpmFactory.descriptor) : } } + /** + * Get workspace module dirs by parsing `pnpm list --json --only-projects --recursive`. + * This implementation only extracts "path" fields from the top-level array entries. + */ private fun getWorkspaceModuleDirs(workingDir: File): Set { - val json = PnpmCommand.run(workingDir, "list", "--json", "--only-projects", "--recursive").requireSuccess() - .stdout + val json = runCatching { + PnpmCommand.run(workingDir, "list", "--json", "--only-projects", "--recursive").requireSuccess().stdout + }.getOrElse { e -> + logger.error(e) { "pnpm list --only-projects failed in $workingDir" } + return emptySet() + } + + val mapper = jacksonObjectMapper() + val root = try { + mapper.readTree(json) + } catch (e: Exception) { + logger.error(e) { "Failed to parse pnpm --only-projects JSON in $workingDir: ${e.message}" } + return emptySet() + } + + // Expecting an array of project objects; fall back gracefully if not. + val dirs = mutableSetOf() + if (root is ArrayNode) { + root.forEach { node -> + val pathNode = node.get("path") + if (pathNode != null && pathNode.isTextual) { + dirs.add(File(pathNode.asText())) + } else { + logger.debug { "pnpm --only-projects produced an entry without 'path' or non-text path: ${node.toString().take(200)}" } + } + } + } else { + logger.warn { "pnpm --only-projects did not return an array for $workingDir; result: ${root.toString().take(200)}" } + } - val listResult = parsePnpmList(json) - return listResult.findModulesFor(workingDir).mapTo(mutableSetOf()) { File(it.path) } + return dirs } + /** + * Run `pnpm list` per workspace package dir for the given scope. + * + * This implementation tries to parse pnpm output, but if parsing is not possible + * it returns an empty list for that scope and logs a warning. Returning an empty + * list is safe: callers skip adding dependencies for that scope rather than throwing. + */ private fun listModules(workingDir: File, scope: Scope): List { val scopeOption = when (scope) { Scope.DEPENDENCIES -> "--prod" Scope.DEV_DEPENDENCIES -> "--dev" } - val json = PnpmCommand.run(workingDir, "list", "--json", "--recursive", "--depth", "Infinity", scopeOption) - .requireSuccess().stdout + val workspaceModuleDirs = getWorkspaceModuleDirs(workingDir) + if (workspaceModuleDirs.isEmpty()) { + logger.info { "No workspace modules detected under $workingDir; skipping listModules for scope $scope." } + return emptyList() + } + + val mapper = jacksonObjectMapper() + val depth = System.getenv("ORT_PNPM_DEPTH")?.toIntOrNull() ?.toString() ?: "Infinity" + logger.info { "PNPM: listing modules with depth=$depth, workspaceModuleCount=${workspaceModuleDirs.size}, workingDir=${workingDir.absolutePath}, scope=$scope" } - return parsePnpmList(json).flatten().toList() + val consolidated = mutableListOf() + + workspaceModuleDirs.forEach { pkgDir -> + val cmdResult = runCatching { + PnpmCommand.run(pkgDir, "list", "--json", "--depth", depth, scopeOption, "--recursive") + .requireSuccess().stdout + }.getOrElse { e -> + logger.warn(e) { "pnpm list failed for package dir: $pkgDir (scope=$scope). Will skip this package for that scope." } + return@forEach + } + + val node = try { + mapper.readTree(cmdResult) + } catch (e: Exception) { + logger.warn(e) { "Failed to parse pnpm list JSON for package dir $pkgDir (scope=$scope): ${e.message}. Skipping." } + return@forEach + } + + // If node is array, collect object children; if object, collect it. + when (node) { + is ArrayNode -> { + node.forEach { elem -> + if (elem != null && elem.isObject) consolidated.add(elem) + else logger.debug { "Skipping non-object element from pnpm list in $pkgDir (scope=$scope): ${elem?.toString()?.take(200)}" } + } + } + else -> if (node.isObject) consolidated.add(node) else logger.debug { "Skipping non-object pnpm list root for $pkgDir (scope=$scope): ${node.toString().take(200)}" } + } + } + + if (consolidated.isEmpty()) { + logger.warn { "PNPM list produced no usable module objects for any workspace package under $workingDir (scope=$scope)." } + return emptyList() + } + + // At this point we would need to map JSON objects to ModuleInfo instances. The exact ModuleInfo + // data class can vary between ORT versions; to avoid compile-time mismatches we try a best-effort + // mapping only for fields we know (name, path, version) and put empty maps for dependency fields. + // If your ModuleInfo has a different constructor, adapt the mapping here accordingly. + + val moduleInfos = mutableListOf() + for (jsonNode in consolidated) { + try { + val name = jsonNode.get("name")?.asText().orEmpty() + val path = jsonNode.get("path")?.asText().orEmpty() + val version = jsonNode.get("version")?.asText().orEmpty() + + // Create a minimal ModuleInfo via its data class constructor if possible. + // Because ModuleInfo's exact constructor can differ across versions, we attempt to + // use a no-argument construction via reflection if available, otherwise skip. + // To keep this conservative and avoid reflection pitfalls, we only call the + // ModuleInfo constructor that takes (name, path, version, ...) if it exists. + // Here we attempt a simple approach: parse into ModuleInfo via mapper, falling back to skip. + val maybe = runCatching { + mapper.treeToValue(jsonNode, ModuleInfo::class.java) + }.getOrElse { + null + } + + if (maybe != null) moduleInfos.add(maybe) + else { + logger.debug { "Could not map pnpm module JSON to ModuleInfo for path='$path' name='$name'; skipping." } + } + } catch (e: Exception) { + logger.debug(e) { "Exception while mapping pnpm module JSON to ModuleInfo: ${e.message}" } + } + } + + if (moduleInfos.isEmpty()) { + logger.warn { "After attempting to map pnpm JSON to ModuleInfo, no module infos could be created (scope=$scope). Skipping." } + } + + return moduleInfos } private fun installDependencies(workingDir: File, scopes: Collection) { @@ -156,7 +310,7 @@ class Pnpm(override val descriptor: PluginDescriptor = PnpmFactory.descriptor) : "install", "--ignore-pnpmfile", "--ignore-scripts", - "--frozen-lockfile", // Use the existing lockfile instead of updating an outdated one. + "--frozen-lockfile", "--prod".takeUnless { Scope.DEV_DEPENDENCIES in scopes } ) @@ -174,20 +328,17 @@ private fun ModuleInfo.getScopeDependencies(scope: Scope) = Scope.DEV_DEPENDENCIES -> devDependencies.values.toList() } -/** - * Find the [List] of [ModuleInfo] objects for the project in the given [workingDir]. If there are nested projects, - * the `pnpm list` command yields multiple arrays with modules. In this case, only the top-level project should be - * analyzed. This function tries to detect the corresponding [ModuleInfo]s based on the [workingDir]. If this is not - * possible, as a fallback the first list of [ModuleInfo] objects is returned. - */ private fun Sequence>.findModulesFor(workingDir: File): List { val moduleInfoIterator = iterator() val first = moduleInfoIterator.nextOrNull() ?: return emptyList() fun List.matchesWorkingDir() = any { File(it.path).absoluteFile == workingDir } - fun findMatchingModules(): List? = - moduleInfoIterator.nextOrNull()?.takeIf { it.matchesWorkingDir() } ?: findMatchingModules() + if (first.matchesWorkingDir()) return first + + for (remaining in moduleInfoIterator) { + if (remaining.matchesWorkingDir()) return remaining + } - return first.takeIf { it.matchesWorkingDir() } ?: findMatchingModules() ?: first + return first }