Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/package-managers/node/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
203 changes: 177 additions & 26 deletions plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,16 +21,23 @@

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
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
Expand All @@ -41,8 +48,13 @@
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

Check warning

Code scanning / detekt

Unused Imports are dead code and should be removed. Warning

The import 'org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm.ModuleInfo' is unused.

Check warning

Code scanning / QDJVMC

Unused import directive Warning

Unused import directive

// 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")

Check warning

Code scanning / QDJVMC

Unused symbol Warning

Property "logger" is never used

internal object PnpmCommand : CommandLineTool {
override fun command(workingDir: File?) = if (Os.isWindows) "pnpm.cmd" else "pnpm"
Expand All @@ -52,6 +64,9 @@

/**
* 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",
Expand All @@ -65,10 +80,8 @@

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 ->
Expand Down Expand Up @@ -97,6 +110,13 @@
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,
Expand All @@ -108,20 +128,38 @@
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. " +

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.
"Available paths: ${candidates.map { it.path }}"
}
}

Check warning

Code scanning / detekt

Reports code blocks that are not followed by an empty line Warning

Missing empty line after block.
// Skip adding dependencies for this scope to avoid exceptions.
} else {
graphBuilder.addDependencies(project.id, scope.descriptor, moduleInfo.getScopeDependencies(scope))
}
}

ProjectAnalyzerResult(
Expand All @@ -131,32 +169,148 @@
}
}

/**
* 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<File> {
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) {

Check warning

Code scanning / detekt

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled. Warning

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
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<File>()
if (root is ArrayNode) {

Check warning on line 194 in plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Unreachable code

Unreachable code

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code
root.forEach { node ->

Check warning on line 195 in plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Unreachable code

Unreachable code

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code
val pathNode = node.get("path")

Check notice on line 196 in plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Explicit 'get' or 'set' call

Explicit 'get' call

Check notice

Code scanning / QDJVMC

Explicit 'get' or 'set' call Note

Explicit 'get' call
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)}" }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.
}
}
} else {
logger.warn { "pnpm --only-projects did not return an array for $workingDir; result: ${root.toString().take(200)}" }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.

Check warning on line 204 in plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Unreachable code

Unreachable code

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code
}

val listResult = parsePnpmList(json)
return listResult.findModulesFor(workingDir).mapTo(mutableSetOf()) { File(it.path) }
return dirs

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code
}

/**
* 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<ModuleInfo> {
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" }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline after "{"

return parsePnpmList(json).flatten().toList()
val consolidated = mutableListOf<JsonNode>()

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." }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.
return@forEach
}

val node = try {
mapper.readTree(cmdResult)
} catch (e: Exception) {

Check warning

Code scanning / detekt

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled. Warning

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
logger.warn(e) { "Failed to parse pnpm list JSON for package dir $pkgDir (scope=$scope): ${e.message}. Skipping." }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.
return@forEach
}

// If node is array, collect object children; if object, collect it.
when (node) {

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code
is ArrayNode -> {

Check warning on line 253 in plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Unreachable code

Unreachable code

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code
node.forEach { elem ->

Check warning on line 254 in plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Unreachable code

Unreachable code

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code
if (elem != null && elem.isObject) consolidated.add(elem)

Check warning

Code scanning / detekt

Detects multiline if-else statements without braces Warning

Missing { ... }
else logger.debug { "Skipping non-object element from pnpm list in $pkgDir (scope=$scope): ${elem?.toString()?.take(200)}" }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.

Check warning

Code scanning / detekt

Detects multiline if-else statements without braces Warning

Missing { ... }

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline after "{"
}
}

Check warning

Code scanning / detekt

Reports code blocks that are not followed by an empty line Warning

Missing empty line after block.
else -> if (node.isObject) consolidated.add(node) else logger.debug { "Skipping non-object pnpm list root for $pkgDir (scope=$scope): ${node.toString().take(200)}" }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline after "{"

Check warning on line 259 in plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Unreachable code

Unreachable code

Check warning on line 259 in plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Unreachable code

Unreachable code

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code

Check warning

Code scanning / QDJVMC

Unreachable code Warning

Unreachable code
}
}

if (consolidated.isEmpty()) {
logger.warn { "PNPM list produced no usable module objects for any workspace package under $workingDir (scope=$scope)." }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline after "{"
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<ModuleInfo>()
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()

Check warning

Code scanning / detekt

Property is unused and should be removed. Warning

Private property version is unused.

Check warning

Code scanning / QDJVMC

Unused variable Warning

Unused variable

// 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)

Check warning

Code scanning / detekt

Detects multiline if-else statements without braces Warning

Missing { ... }
else {
logger.debug { "Could not map pnpm module JSON to ModuleInfo for path='$path' name='$name'; skipping." }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.
}
} catch (e: Exception) {

Check warning

Code scanning / detekt

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled. Warning

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
logger.debug(e) { "Exception while mapping pnpm module JSON to ModuleInfo: ${e.message}" }
}
}

if (moduleInfos.isEmpty()) {

Check warning on line 301 in plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Constant conditions

Condition 'moduleInfos.isEmpty()' is always true

Check warning

Code scanning / QDJVMC

Constant conditions Warning

Condition 'moduleInfos.isEmpty()' is always true
logger.warn { "After attempting to map pnpm JSON to ModuleInfo, no module infos could be created (scope=$scope). Skipping." }

Check warning

Code scanning / detekt

Line detected, which is longer than the defined maximum line length in the code style. Warning

Line detected, which is longer than the defined maximum line length in the code style.

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline after "{"
}

return moduleInfos
}

private fun installDependencies(workingDir: File, scopes: Collection<Scope>) {
val args = listOfNotNull(
"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 }
)

Expand All @@ -174,20 +328,17 @@
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<List<ModuleInfo>>.findModulesFor(workingDir: File): List<ModuleInfo> {
val moduleInfoIterator = iterator()
val first = moduleInfoIterator.nextOrNull() ?: return emptyList()

fun List<ModuleInfo>.matchesWorkingDir() = any { File(it.path).absoluteFile == workingDir }

fun findMatchingModules(): List<ModuleInfo>? =
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
}
Loading