1
1
/*
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>)
3
3
*
4
4
* Licensed under the Apache License, Version 2.0 (the "License");
5
5
* you may not use this file except in compliance with the License.
@@ -22,13 +22,33 @@ package org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm
22
22
import java.io.File
23
23
24
24
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
25
35
import org.ossreviewtoolkit.model.config.AnalyzerConfiguration
26
36
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
28
41
import org.ossreviewtoolkit.plugins.packagemanagers.node.utils.NodePackageManager
29
42
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
30
50
import org.ossreviewtoolkit.utils.common.Os
31
- import org.ossreviewtoolkit.utils.common.realFile
51
+ import org.ossreviewtoolkit.utils.common.stashDirectories
32
52
33
53
import org.semver4j.RangesList
34
54
import org.semver4j.RangesListFactory
@@ -41,7 +61,7 @@ class Pnpm(
41
61
analysisRoot : File ,
42
62
analyzerConfig : AnalyzerConfiguration ,
43
63
repoConfig : RepositoryConfiguration
44
- ) : Npm (name, analysisRoot, analyzerConfig, repoConfig) {
64
+ ) : PackageManager (name, analysisRoot, analyzerConfig, repoConfig), CommandLineTool {
45
65
class Factory : AbstractPackageManagerFactory <Pnpm >(" PNPM" ) {
46
66
override val globsForDefinitionFiles = listOf (" package.json" , " pnpm-lock.yaml" )
47
67
@@ -52,14 +72,44 @@ class Pnpm(
52
72
) = Pnpm (type, analysisRoot, analyzerConfig, repoConfig)
53
73
}
54
74
55
- override fun hasLockfile (projectDir : File ) = NodePackageManager .PNPM .hasLockfile(projectDir)
75
+ private val handler = PnpmDependencyHandler ()
76
+ private val graphBuilder by lazy { DependencyGraphBuilder (handler) }
56
77
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 }
58
99
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
+ }
61
103
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
+ }
63
113
}
64
114
65
115
override fun command (workingDir : File ? ) =
@@ -69,18 +119,37 @@ class Pnpm(
69
119
" /home/frank/.nvm/versions/node/v18.12.1/bin/node /home/frank/.nvm/versions/node/v18.12.1/bin/pnpm"
70
120
}
71
121
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
+
72
142
override fun getVersionRequirement (): RangesList = RangesListFactory .create(" 5.* - 9.*" )
73
143
74
144
override fun mapDefinitionFiles (definitionFiles : List <File >) =
75
145
NpmDetection (definitionFiles).filterApplicable(NodePackageManager .PNPM )
76
146
77
- override fun runInstall (workingDir : File ) =
147
+ private fun installDependencies (workingDir : File ) =
78
148
run (
79
149
" install" ,
80
150
" --ignore-pnpmfile" ,
81
151
" --ignore-scripts" ,
82
152
" --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.
84
153
workingDir = workingDir
85
154
)
86
155
@@ -89,3 +158,84 @@ class Pnpm(
89
158
// fixed major version to be sure to get consistent results.
90
159
checkVersion()
91
160
}
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
+ }
0 commit comments