Skip to content

Commit f29ee32

Browse files
committed
refactor(pnpm): Make Pnpm separate from Npm
Stop inheriting from `Npm` and rely entirely on the output of `pnpm` commands to figure out the necessary information. For example, do not re-construct the dependency from the file structure within `node_modles`, but rely on `pnpm list` instead. This tremendously reduces complexity and makes the implementation easy to understand and maintain. Apart from that it corrects issues with the dependency tree, such as: 1. htmlparser2 now dependends on domutils 1.7.0 instead of 1.5.1, which is inline with the dependency tree output by `pnpm list --depth 1000` 2. The cyclic reference from the workspace root project to itself is now included. Signed-off-by: Frank Viernau <[email protected]>
1 parent b31e245 commit f29ee32

File tree

4 files changed

+277
-12
lines changed

4 files changed

+277
-12
lines changed

plugins/package-managers/node/src/main/kotlin/Npm.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,7 @@ internal fun List<String>.groupLines(vararg markers: String): List<String> {
611611
}
612612
}
613613

614-
private fun parseProject(packageJsonFile: File, analysisRoot: File, managerName: String): Project {
614+
internal fun parseProject(packageJsonFile: File, analysisRoot: File, managerName: String): Project {
615615
Npm.logger.debug { "Parsing project info from '$packageJsonFile'." }
616616

617617
val packageJson = parsePackageJson(packageJsonFile)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (C) 2024 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm
21+
22+
import kotlinx.serialization.Serializable
23+
import kotlinx.serialization.json.Json
24+
25+
private val JSON = Json { ignoreUnknownKeys = true }
26+
27+
internal fun parsePnpmList(json: String): List<ModuleInfo> = JSON.decodeFromString(json)
28+
29+
@Serializable
30+
internal data class ModuleInfo(
31+
val name: String,
32+
val version: String,
33+
val path: String,
34+
val private: Boolean,
35+
val dependencies: Map<String, Dependency> = emptyMap(),
36+
val devDependencies: Map<String, Dependency> = emptyMap(),
37+
val optionalDependencies: Map<String, Dependency> = emptyMap()
38+
) {
39+
@Serializable
40+
data class Dependency(
41+
val from: String,
42+
val version: String,
43+
val resolved: String? = null,
44+
val path: String,
45+
val dependencies: Map<String, Dependency> = emptyMap(),
46+
val optionalDependencies: Map<String, Dependency> = emptyMap()
47+
)
48+
}

plugins/package-managers/node/src/main/kotlin/pnpm/Pnpm.kt

Lines changed: 161 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2019 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
2+
* Copyright (C) 2024 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -22,13 +22,33 @@ package org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm
2222
import java.io.File
2323

2424
import org.ossreviewtoolkit.analyzer.AbstractPackageManagerFactory
25+
import org.ossreviewtoolkit.analyzer.PackageManager
26+
import org.ossreviewtoolkit.analyzer.PackageManager.Companion.processPackageVcs
27+
import org.ossreviewtoolkit.analyzer.PackageManagerResult
28+
import org.ossreviewtoolkit.downloader.VcsHost
29+
import org.ossreviewtoolkit.model.DependencyGraph
30+
import org.ossreviewtoolkit.model.Hash
31+
import org.ossreviewtoolkit.model.Identifier
32+
import org.ossreviewtoolkit.model.Package
33+
import org.ossreviewtoolkit.model.ProjectAnalyzerResult
34+
import org.ossreviewtoolkit.model.RemoteArtifact
2535
import org.ossreviewtoolkit.model.config.AnalyzerConfiguration
2636
import org.ossreviewtoolkit.model.config.RepositoryConfiguration
27-
import org.ossreviewtoolkit.plugins.packagemanagers.node.Npm
37+
import org.ossreviewtoolkit.model.utils.DependencyGraphBuilder
38+
import org.ossreviewtoolkit.plugins.packagemanagers.node.parsePackageJson
39+
import org.ossreviewtoolkit.plugins.packagemanagers.node.parseProject
40+
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.NON_EXISTING_SEMVER
2841
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.NodePackageManager
2942
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.NpmDetection
43+
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.expandNpmShortcutUrl
44+
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.fixNpmDownloadUrl
45+
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.mapNpmLicenses
46+
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.parseNpmAuthor
47+
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.parseNpmVcsInfo
48+
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.splitNpmNamespaceAndName
49+
import org.ossreviewtoolkit.utils.common.CommandLineTool
3050
import org.ossreviewtoolkit.utils.common.Os
31-
import org.ossreviewtoolkit.utils.common.realFile
51+
import org.ossreviewtoolkit.utils.common.stashDirectories
3252

3353
import org.semver4j.RangesList
3454
import org.semver4j.RangesListFactory
@@ -41,7 +61,7 @@ class Pnpm(
4161
analysisRoot: File,
4262
analyzerConfig: AnalyzerConfiguration,
4363
repoConfig: RepositoryConfiguration
44-
) : Npm(name, analysisRoot, analyzerConfig, repoConfig) {
64+
) : PackageManager(name, analysisRoot, analyzerConfig, repoConfig), CommandLineTool {
4565
class Factory : AbstractPackageManagerFactory<Pnpm>("PNPM") {
4666
override val globsForDefinitionFiles = listOf("package.json", "pnpm-lock.yaml")
4767

@@ -52,14 +72,44 @@ class Pnpm(
5272
) = Pnpm(type, analysisRoot, analyzerConfig, repoConfig)
5373
}
5474

55-
override fun hasLockfile(projectDir: File) = NodePackageManager.PNPM.hasLockfile(projectDir)
75+
private val handler = PnpmDependencyHandler()
76+
private val graphBuilder by lazy { DependencyGraphBuilder(handler) }
5677

57-
override fun File.isWorkspaceDir() = realFile() in findWorkspaceSubmodules(analysisRoot)
78+
override fun resolveDependencies(definitionFile: File, labels: Map<String, String>): List<ProjectAnalyzerResult> =
79+
stashDirectories(definitionFile.resolveSibling("node_modules")).use {
80+
resolveDependencies(definitionFile)
81+
}
82+
83+
private fun resolveDependencies(definitionFile: File): List<ProjectAnalyzerResult> {
84+
val workingDir = definitionFile.parentFile
85+
installDependencies(workingDir)
86+
87+
val workspaceModuleDirs = getWorkspaceModuleDirs(workingDir)
88+
handler.setWorkspaceModuleDirs(workspaceModuleDirs)
89+
90+
val moduleInfosForScope = Scope.entries.associateWith { scope -> listModules(workingDir, scope) }
91+
92+
return workspaceModuleDirs.map { projectDir ->
93+
val project = parseProject(projectDir.resolve("package.json"), analysisRoot, managerName)
94+
95+
val scopeNames = Scope.entries.mapTo(mutableSetOf()) { scope ->
96+
val scopeName = scope.toString()
97+
val qualifiedScopeName = DependencyGraph.qualifyScope(project, scope.toString())
98+
val moduleInfo = moduleInfosForScope.getValue(scope).single { it.path == projectDir.absolutePath }
5899

59-
override fun loadWorkspaceSubmodules(moduleDir: File): Set<File> {
60-
val process = run(moduleDir, "list", "--recursive", "--depth=-1", "--parseable")
100+
moduleInfo.getScopeDependencies(scope).forEach { dependency ->
101+
graphBuilder.addDependency(qualifiedScopeName, dependency)
102+
}
61103

62-
return process.stdout.lines().filter { it.isNotEmpty() }.mapTo(mutableSetOf()) { File(it) }
104+
scopeName
105+
}
106+
107+
ProjectAnalyzerResult(
108+
project = project.copy(scopeNames = scopeNames),
109+
packages = emptySet(),
110+
issues = emptyList()
111+
)
112+
}
63113
}
64114

65115
override fun command(workingDir: File?) =
@@ -69,18 +119,37 @@ class Pnpm(
69119
"/home/frank/.nvm/versions/node/v18.12.1/bin/node /home/frank/.nvm/versions/node/v18.12.1/bin/pnpm"
70120
}
71121

122+
private fun getWorkspaceModuleDirs(workingDir: File): Set<File> {
123+
val json = run(workingDir, "list", "--json", "--only-projects", "--recursive").stdout
124+
125+
return parsePnpmList(json).mapTo(mutableSetOf()) { File(it.path) }
126+
}
127+
128+
private fun listModules(workingDir: File, scope: Scope): List<ModuleInfo> {
129+
val scopeOption = when (scope) {
130+
Scope.DEPENDENCIES -> "--prod"
131+
Scope.DEV_DEPENDENCIES -> "--dev"
132+
}
133+
134+
val json = run(workingDir, "list", "--json", "--recursive", "--depth", "10000", scopeOption).stdout
135+
136+
return parsePnpmList(json)
137+
}
138+
139+
override fun createPackageManagerResult(projectResults: Map<File, List<ProjectAnalyzerResult>>) =
140+
PackageManagerResult(projectResults, graphBuilder.build(), graphBuilder.packages())
141+
72142
override fun getVersionRequirement(): RangesList = RangesListFactory.create("5.* - 9.*")
73143

74144
override fun mapDefinitionFiles(definitionFiles: List<File>) =
75145
NpmDetection(definitionFiles).filterApplicable(NodePackageManager.PNPM)
76146

77-
override fun runInstall(workingDir: File) =
147+
private fun installDependencies(workingDir: File) =
78148
run(
79149
"install",
80150
"--ignore-pnpmfile",
81151
"--ignore-scripts",
82152
"--frozen-lockfile", // Use the existing lockfile instead of updating an outdated one.
83-
"--shamefully-hoist", // Build a similar node_modules structure as NPM and Yarn does.
84153
workingDir = workingDir
85154
)
86155

@@ -89,3 +158,84 @@ class Pnpm(
89158
// fixed major version to be sure to get consistent results.
90159
checkVersion()
91160
}
161+
162+
private enum class Scope(val descriptor: String) {
163+
DEPENDENCIES("dependencies"),
164+
DEV_DEPENDENCIES("devDependencies");
165+
166+
override fun toString(): String = descriptor
167+
}
168+
169+
private fun ModuleInfo.getScopeDependencies(scope: Scope) =
170+
when (scope) {
171+
Scope.DEPENDENCIES -> buildList {
172+
addAll(dependencies.values)
173+
addAll(optionalDependencies.values)
174+
}
175+
176+
Scope.DEV_DEPENDENCIES -> devDependencies.values.toList()
177+
}
178+
179+
internal fun parsePackage(packageJsonFile: File): Package {
180+
val packageJson = parsePackageJson(packageJsonFile)
181+
182+
// The "name" and "version" fields are only required if the package is going to be published, otherwise they are
183+
// optional, see
184+
// - https://docs.npmjs.com/cli/v10/configuring-npm/package-json#name
185+
// - https://docs.npmjs.com/cli/v10/configuring-npm/package-json#version
186+
// So, projects analyzed by ORT might not have these fields set.
187+
val rawName = packageJson.name.orEmpty() // TODO: Fall back to a generated name if the name is unset.
188+
val (namespace, name) = splitNpmNamespaceAndName(rawName)
189+
val version = packageJson.version ?: NON_EXISTING_SEMVER
190+
191+
val declaredLicenses = packageJson.licenses.mapNpmLicenses()
192+
val authors = parseNpmAuthor(packageJson.authors.firstOrNull()) // TODO: parse all authors.
193+
194+
var description = packageJson.description.orEmpty()
195+
var homepageUrl = packageJson.homepage.orEmpty()
196+
197+
// Note that all fields prefixed with "_" are considered private to NPM and should not be relied on.
198+
var downloadUrl = expandNpmShortcutUrl(packageJson.resolved.orEmpty()).ifEmpty {
199+
// If the normalized form of the specified dependency contains a URL as the version, expand and use it.
200+
val fromVersion = packageJson.from.orEmpty().substringAfterLast('@')
201+
expandNpmShortcutUrl(fromVersion).takeIf { it != fromVersion }.orEmpty()
202+
}
203+
204+
var hash = Hash.create(packageJson.integrity.orEmpty())
205+
206+
var vcsFromPackage = parseNpmVcsInfo(packageJson)
207+
208+
val id = Identifier("NPM", namespace, name, version)
209+
210+
downloadUrl = downloadUrl.fixNpmDownloadUrl()
211+
212+
val vcsFromDownloadUrl = VcsHost.parseUrl(downloadUrl)
213+
if (vcsFromDownloadUrl.url != downloadUrl) {
214+
vcsFromPackage = vcsFromPackage.merge(vcsFromDownloadUrl)
215+
}
216+
217+
val module = Package(
218+
id = id,
219+
authors = authors,
220+
declaredLicenses = declaredLicenses,
221+
description = description,
222+
homepageUrl = homepageUrl,
223+
binaryArtifact = RemoteArtifact.EMPTY,
224+
sourceArtifact = RemoteArtifact(
225+
url = VcsHost.toArchiveDownloadUrl(vcsFromDownloadUrl) ?: downloadUrl,
226+
hash = hash
227+
),
228+
vcs = vcsFromPackage,
229+
vcsProcessed = processPackageVcs(vcsFromPackage, homepageUrl)
230+
)
231+
232+
require(module.id.name.isNotEmpty()) {
233+
"Generated package info for '${id.toCoordinates()}' has no name."
234+
}
235+
236+
require(module.id.version.isNotEmpty()) {
237+
"Generated package info for '${id.toCoordinates()}' has no version."
238+
}
239+
240+
return module
241+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright (C) 2024 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm
21+
22+
import java.io.File
23+
24+
import org.ossreviewtoolkit.model.Identifier
25+
import org.ossreviewtoolkit.model.Issue
26+
import org.ossreviewtoolkit.model.Package
27+
import org.ossreviewtoolkit.model.PackageLinkage
28+
import org.ossreviewtoolkit.model.utils.DependencyHandler
29+
import org.ossreviewtoolkit.plugins.packagemanagers.node.parsePackageJson
30+
import org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm.ModuleInfo.Dependency
31+
import org.ossreviewtoolkit.utils.common.realFile
32+
33+
internal class PnpmDependencyHandler : DependencyHandler<Dependency> {
34+
private val workspaceModuleDirs = mutableSetOf<File>()
35+
36+
private fun Dependency.isProject() = File(path).realFile().absoluteFile in workspaceModuleDirs
37+
38+
fun setWorkspaceModuleDirs(dirs: Collection<File>) {
39+
workspaceModuleDirs.apply {
40+
clear()
41+
addAll(dirs)
42+
}
43+
}
44+
45+
override fun identifierFor(dependency: Dependency): Identifier {
46+
val type = "PNPM".takeIf { dependency.isProject() } ?: "NPM"
47+
val namespace = dependency.from.substringBeforeLast("/", "")
48+
val name = dependency.from.substringAfterLast("/")
49+
val version = if (dependency.isProject()) {
50+
// TODO: Read each package.json only once?! (use caching).
51+
parsePackageJson(File(dependency.path).resolve("package.json")).version.orEmpty()
52+
} else {
53+
dependency.version.takeUnless { it.startsWith("link:") || it.startsWith("file:") }.orEmpty()
54+
}
55+
56+
return Identifier(type, namespace, name, version)
57+
}
58+
59+
override fun dependenciesFor(dependency: Dependency): List<Dependency> =
60+
(dependency.dependencies + dependency.optionalDependencies).values.toList()
61+
62+
override fun linkageFor(dependency: Dependency): PackageLinkage =
63+
PackageLinkage.DYNAMIC.takeUnless { dependency.isProject() } ?: PackageLinkage.PROJECT_DYNAMIC
64+
65+
override fun createPackage(dependency: Dependency, issues: MutableCollection<Issue>): Package? =
66+
dependency.takeUnless { it.isProject() }?.let { parsePackage(File(it.path).resolve("package.json")) }
67+
}

0 commit comments

Comments
 (0)