Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Node package manager improvements #9426

Merged
merged 5 commits into from
Jan 16, 2025
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright (C) 2025 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* 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
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
* License-Filename: LICENSE
*/

package org.ossreviewtoolkit.plugins.packagemanagers.node

import java.io.File

import org.apache.logging.log4j.kotlin.logger

import org.ossreviewtoolkit.analyzer.PackageManager
import org.ossreviewtoolkit.analyzer.PackageManagerResult
import org.ossreviewtoolkit.analyzer.parseAuthorString
import org.ossreviewtoolkit.downloader.VersionControlSystem
import org.ossreviewtoolkit.model.Identifier
import org.ossreviewtoolkit.model.Project
import org.ossreviewtoolkit.model.ProjectAnalyzerResult
import org.ossreviewtoolkit.model.config.AnalyzerConfiguration
import org.ossreviewtoolkit.model.config.RepositoryConfiguration
import org.ossreviewtoolkit.model.utils.DependencyGraphBuilder
import org.ossreviewtoolkit.utils.common.realFile

abstract class NodePackageManager(
managerName: String,
val managerType: NodePackageManagerType,
analysisRoot: File,
analyzerConfig: AnalyzerConfiguration,
repoConfig: RepositoryConfiguration
) : PackageManager(managerName, managerType.projectType, analysisRoot, analyzerConfig, repoConfig) {
protected abstract val graphBuilder: DependencyGraphBuilder<*>

protected fun parseProject(packageJsonFile: File, analysisRoot: File): Project {
logger.debug { "Parsing project info from '$packageJsonFile'." }

val packageJson = parsePackageJson(packageJsonFile)

val rawName = packageJson.name.orEmpty()
val (namespace, name) = splitNamespaceAndName(rawName)

val projectName = name.ifBlank {
getFallbackProjectName(analysisRoot, packageJsonFile).also {
logger.warn { "'$packageJsonFile' does not define a name, falling back to '$it'." }
}
}

val version = packageJson.version.orEmpty()
if (version.isBlank()) {
logger.warn { "'$packageJsonFile' does not define a version." }
}

val declaredLicenses = packageJson.licenses.mapLicenses()
val authors = packageJson.authors.flatMap { parseAuthorString(it.name) }
.mapNotNullTo(mutableSetOf()) { it.name }
val description = packageJson.description.orEmpty()
val homepageUrl = packageJson.homepage.orEmpty()
val projectDir = packageJsonFile.parentFile.realFile()
val vcsFromPackage = parseVcsInfo(packageJson)

return Project(
id = Identifier(
type = projectType,
namespace = namespace,
name = projectName,
version = version
),
definitionFilePath = VersionControlSystem.getPathInfo(packageJsonFile.realFile()).path,
authors = authors,
declaredLicenses = declaredLicenses,
vcs = vcsFromPackage,
vcsProcessed = processProjectVcs(projectDir, vcsFromPackage, homepageUrl),
description = description,
homepageUrl = homepageUrl
)
}

override fun mapDefinitionFiles(definitionFiles: List<File>) =
NodePackageManagerDetection(definitionFiles).filterApplicable(managerType)

override fun createPackageManagerResult(projectResults: Map<File, List<ProjectAnalyzerResult>>) =
PackageManagerResult(projectResults, graphBuilder.build(), graphBuilder.packages())
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ import org.ossreviewtoolkit.utils.common.collectMessages
/**
* A class to detect the package managers used for the give [definitionFiles].
*/
internal class NpmDetection(private val definitionFiles: Collection<File>) {
internal class NodePackageManagerDetection(private val definitionFiles: Collection<File>) {
/**
* A map of project directories to the set of package managers that are most likely responsible for the project. If
* the set is empty, none of the package managers is responsible.
*/
private val projectDirManagers: Map<File, Set<NodePackageManager>> by lazy {
private val projectDirManagers: Map<File, Set<NodePackageManagerType>> by lazy {
definitionFiles.associate { file ->
val projectDir = file.parentFile
projectDir to NodePackageManager.forDirectory(projectDir)
projectDir to NodePackageManagerType.forDirectory(projectDir)
}
}

Expand All @@ -50,7 +50,7 @@ internal class NpmDetection(private val definitionFiles: Collection<File>) {
private val workspacePatterns: Map<File, List<PathMatcher>> by lazy {
definitionFiles.associate { file ->
val projectDir = file.parentFile
val patterns = NodePackageManager.entries.mapNotNull { it.getWorkspaces(projectDir) }.flatten()
val patterns = NodePackageManagerType.entries.mapNotNull { it.getWorkspaces(projectDir) }.flatten()
projectDir to patterns.map {
FileSystems.getDefault().getPathMatcher("glob:${it.removeSuffix("/")}")
}
Expand All @@ -70,7 +70,7 @@ internal class NpmDetection(private val definitionFiles: Collection<File>) {
/**
* Return those [definitionFiles] that define root projects for the given [manager].
*/
fun filterApplicable(manager: NodePackageManager): List<File> =
fun filterApplicable(manager: NodePackageManagerType): List<File> =
definitionFiles.filter { file ->
val projectDir = file.parentFile

Expand Down Expand Up @@ -103,19 +103,21 @@ internal class NpmDetection(private val definitionFiles: Collection<File>) {
"Any of $managersFromFiles could be the package manager for '$file'. Assuming it is an NPM project."
}

manager == NodePackageManager.NPM
manager == NodePackageManagerType.NPM
}
}

/**
* An enum of all supported Node package managers.
*/
internal enum class NodePackageManager(
enum class NodePackageManagerType(
val projectType: String,
val lockfileName: String,
val markerFileName: String? = null,
val workspaceFileName: String = NodePackageManager.DEFINITION_FILE
val workspaceFileName: String = NodePackageManagerType.DEFINITION_FILE
) {
NPM(
projectType = "NPM",
lockfileName = "package-lock.json", // See https://docs.npmjs.com/cli/v7/configuring-npm/package-lock-json.
markerFileName = "npm-shrinkwrap.json" // See https://docs.npmjs.com/cli/v6/configuring-npm/shrinkwrap-json.
) {
Expand All @@ -124,6 +126,7 @@ internal enum class NodePackageManager(
},

PNPM(
projectType = "PNPM",
lockfileName = "pnpm-lock.yaml", // See https://pnpm.io/git#lockfiles.
workspaceFileName = "pnpm-workspace.yaml"
) {
Expand All @@ -142,6 +145,7 @@ internal enum class NodePackageManager(
},

YARN(
projectType = "Yarn",
lockfileName = "yarn.lock" // See https://classic.yarnpkg.com/en/docs/yarn-lock.
) {
private val lockfileMarker = "# yarn lockfile v1"
Expand All @@ -157,6 +161,7 @@ internal enum class NodePackageManager(
},

YARN2(
projectType = "Yarn2",
lockfileName = "yarn.lock", // See https://classic.yarnpkg.com/en/docs/yarn-lock.
markerFileName = ".yarnrc.yml"
) {
Expand Down Expand Up @@ -186,8 +191,8 @@ internal enum class NodePackageManager(
/**
* Return the set of package managers that are most likely responsible for the given [projectDir].
*/
fun forDirectory(projectDir: File): Set<NodePackageManager> {
val scores = NodePackageManager.entries.associateWith {
fun forDirectory(projectDir: File): Set<NodePackageManagerType> {
val scores = NodePackageManagerType.entries.associateWith {
it.getFileScore(projectDir)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,21 @@
package org.ossreviewtoolkit.plugins.packagemanagers.node

import java.io.File
import java.lang.invoke.MethodHandles

import org.apache.logging.log4j.kotlin.loggerOf

import org.ossreviewtoolkit.analyzer.PackageManager.Companion.getFallbackProjectName
import org.ossreviewtoolkit.analyzer.PackageManager.Companion.processPackageVcs
import org.ossreviewtoolkit.analyzer.PackageManager.Companion.processProjectVcs
import org.ossreviewtoolkit.analyzer.parseAuthorString
import org.ossreviewtoolkit.downloader.VcsHost
import org.ossreviewtoolkit.downloader.VersionControlSystem
import org.ossreviewtoolkit.model.Hash
import org.ossreviewtoolkit.model.Identifier
import org.ossreviewtoolkit.model.Package
import org.ossreviewtoolkit.model.Project
import org.ossreviewtoolkit.model.RemoteArtifact
import org.ossreviewtoolkit.model.VcsInfo
import org.ossreviewtoolkit.model.VcsType
import org.ossreviewtoolkit.utils.common.realFile
import org.ossreviewtoolkit.utils.common.toUri
import org.ossreviewtoolkit.utils.spdx.SpdxConstants

internal const val NON_EXISTING_SEMVER = "0.0.0"

private val logger = loggerOf(MethodHandles.lookup().lookupClass())

/**
* Expand an NPM shortcut [url] to a regular URL as used for dependencies, see
* https://docs.npmjs.com/cli/v7/configuring-npm/package-json#urls-as-dependencies.
Expand Down Expand Up @@ -113,7 +103,7 @@
}

/**
* Parse information about the VCS from the [package.json][node] file of a module.

Check warning on line 106 in plugins/package-managers/node/src/main/kotlin/NodePackageManagerSupport.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Unresolved reference in KDoc

Cannot resolve symbol 'node'
*/
internal fun parseVcsInfo(packageJson: PackageJson): VcsInfo {
// See https://github.com/npm/read-package-json/issues/7 for some background info.
Expand Down Expand Up @@ -228,49 +218,6 @@
return module
}

internal fun parseProject(packageJsonFile: File, analysisRoot: File, managerName: String): Project {
logger.debug { "Parsing project info from '$packageJsonFile'." }

val packageJson = parsePackageJson(packageJsonFile)

val rawName = packageJson.name.orEmpty()
val (namespace, name) = splitNamespaceAndName(rawName)

val projectName = name.ifBlank {
getFallbackProjectName(analysisRoot, packageJsonFile).also {
logger.warn { "'$packageJsonFile' does not define a name, falling back to '$it'." }
}
}

val version = packageJson.version.orEmpty()
if (version.isBlank()) {
logger.warn { "'$packageJsonFile' does not define a version." }
}

val declaredLicenses = packageJson.licenses.mapLicenses()
val authors = packageJson.authors.flatMap { parseAuthorString(it.name) }.mapNotNullTo(mutableSetOf()) { it.name }
val description = packageJson.description.orEmpty()
val homepageUrl = packageJson.homepage.orEmpty()
val projectDir = packageJsonFile.parentFile.realFile()
val vcsFromPackage = parseVcsInfo(packageJson)

return Project(
id = Identifier(
type = managerName,
namespace = namespace,
name = projectName,
version = version
),
definitionFilePath = VersionControlSystem.getPathInfo(packageJsonFile.realFile()).path,
authors = authors,
declaredLicenses = declaredLicenses,
vcs = vcsFromPackage,
vcsProcessed = processProjectVcs(projectDir, vcsFromPackage, homepageUrl),
description = description,
homepageUrl = homepageUrl
)
}

/**
* Split the given [rawName] of a module to a pair with namespace and name.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ internal fun parseNpmList(json: String): ModuleInfo = JSON.decodeFromString(json
* Module information for installed NPM packages.
*/
@Serializable
internal data class ModuleInfo(
data class ModuleInfo(
/** The name of the package. */
val name: String? = null,

Expand Down
28 changes: 9 additions & 19 deletions plugins/package-managers/node/src/main/kotlin/npm/Npm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ import java.util.LinkedList
import org.apache.logging.log4j.kotlin.logger

import org.ossreviewtoolkit.analyzer.AbstractPackageManagerFactory
import org.ossreviewtoolkit.analyzer.PackageManager
import org.ossreviewtoolkit.analyzer.PackageManagerResult
import org.ossreviewtoolkit.model.Issue
import org.ossreviewtoolkit.model.Project
import org.ossreviewtoolkit.model.ProjectAnalyzerResult
Expand All @@ -36,10 +34,9 @@ import org.ossreviewtoolkit.model.config.PackageManagerConfiguration
import org.ossreviewtoolkit.model.config.RepositoryConfiguration
import org.ossreviewtoolkit.model.utils.DependencyGraphBuilder
import org.ossreviewtoolkit.plugins.packagemanagers.node.NodePackageManager
import org.ossreviewtoolkit.plugins.packagemanagers.node.NpmDetection
import org.ossreviewtoolkit.plugins.packagemanagers.node.NodePackageManagerType
import org.ossreviewtoolkit.plugins.packagemanagers.node.PackageJson
import org.ossreviewtoolkit.plugins.packagemanagers.node.parsePackageJson
import org.ossreviewtoolkit.plugins.packagemanagers.node.parseProject
import org.ossreviewtoolkit.utils.common.CommandLineTool
import org.ossreviewtoolkit.utils.common.Os
import org.ossreviewtoolkit.utils.common.ProcessCapture
Expand Down Expand Up @@ -70,14 +67,14 @@ class Npm(
analysisRoot: File,
analyzerConfig: AnalyzerConfiguration,
repoConfig: RepositoryConfiguration
) : PackageManager(name, "NPM", analysisRoot, analyzerConfig, repoConfig) {
) : NodePackageManager(name, NodePackageManagerType.NPM, analysisRoot, analyzerConfig, repoConfig) {
companion object {
/** Name of the configuration option to toggle legacy peer dependency support. */
const val OPTION_LEGACY_PEER_DEPS = "legacyPeerDeps"
}

class Factory : AbstractPackageManagerFactory<Npm>("NPM") {
override val globsForDefinitionFiles = listOf(NodePackageManager.DEFINITION_FILE)
override val globsForDefinitionFiles = listOf(NodePackageManagerType.DEFINITION_FILE)

override fun create(
analysisRoot: File,
Expand All @@ -89,7 +86,8 @@ class Npm(
private val legacyPeerDeps = options[OPTION_LEGACY_PEER_DEPS].toBoolean()
private val npmViewCache = mutableMapOf<String, PackageJson>()
private val handler = NpmDependencyHandler(projectType, this::getRemotePackageDetails)
private val graphBuilder by lazy { DependencyGraphBuilder(handler) }

override val graphBuilder by lazy { DependencyGraphBuilder(handler) }

override fun resolveDependencies(definitionFile: File, labels: Map<String, String>): List<ProjectAnalyzerResult> =
stashDirectories(definitionFile.resolveSibling("node_modules")).use {
Expand All @@ -102,7 +100,7 @@ class Npm(

if (issues.any { it.severity == Severity.ERROR }) {
val project = runCatching {
parseProject(definitionFile, analysisRoot, managerName)
parseProject(definitionFile, analysisRoot)
}.getOrElse {
logger.error { "Failed to parse project information: ${it.collectMessages()}" }
Project.EMPTY
Expand All @@ -111,7 +109,7 @@ class Npm(
return listOf(ProjectAnalyzerResult(project, emptySet(), issues))
}

val project = parseProject(definitionFile, analysisRoot, managerName)
val project = parseProject(definitionFile, analysisRoot)
val projectModuleInfo = listModules(workingDir, issues).undoDeduplication()

val scopeNames = Scope.entries
Expand All @@ -131,20 +129,12 @@ class Npm(
).let { listOf(it) }
}

private fun hasLockfile(projectDir: File) = NodePackageManager.NPM.hasLockfile(projectDir)

override fun mapDefinitionFiles(definitionFiles: List<File>) =
NpmDetection(definitionFiles).filterApplicable(NodePackageManager.NPM)

override fun beforeResolution(definitionFiles: List<File>) {
// We do not actually depend on any features specific to an NPM version, but we still want to stick to a
// fixed minor version to be sure to get consistent results.
NpmCommand.checkVersion()
}

override fun createPackageManagerResult(projectResults: Map<File, List<ProjectAnalyzerResult>>) =
PackageManagerResult(projectResults, graphBuilder.build(), graphBuilder.packages())

private fun listModules(workingDir: File, issues: MutableList<Issue>): ModuleInfo {
val listProcess = NpmCommand.run(workingDir, "list", "--depth", "Infinity", "--json", "--long")
issues += listProcess.extractNpmIssues()
Expand All @@ -167,15 +157,15 @@ class Npm(
}

private fun installDependencies(workingDir: File): List<Issue> {
requireLockfile(workingDir) { hasLockfile(workingDir) }
requireLockfile(workingDir) { managerType.hasLockfile(workingDir) }

val options = listOfNotNull(
"--ignore-scripts",
"--no-audit",
"--legacy-peer-deps".takeIf { legacyPeerDeps }
)

val subcommand = if (hasLockfile(workingDir)) "ci" else "install"
val subcommand = if (managerType.hasLockfile(workingDir)) "ci" else "install"

val process = NpmCommand.run(workingDir, subcommand, *options.toTypedArray())

Expand Down
Loading
Loading