5
5
* you may not use this file except in compliance with the License.
6
6
* You may obtain a copy of the License at
7
7
*
8
- * https ://www.apache.org/licenses/LICENSE-2.0
8
+ * http ://www.apache.org/licenses/LICENSE-2.0
9
9
*
10
10
* Unless required by applicable law or agreed to in writing, software
11
11
* distributed under the License is distributed on an "AS IS" BASIS,
@@ -21,16 +21,23 @@ package org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm
21
21
22
22
import java.io.File
23
23
24
+ import com.fasterxml.jackson.databind.JsonNode
25
+ import com.fasterxml.jackson.databind.node.ArrayNode
26
+ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
27
+
28
+ import org.apache.logging.log4j.LogManager
24
29
import org.apache.logging.log4j.kotlin.logger
25
30
31
+ import org.semver4j.range.RangeList
32
+ import org.semver4j.range.RangeListFactory
33
+
26
34
import org.ossreviewtoolkit.analyzer.PackageManagerFactory
27
35
import org.ossreviewtoolkit.model.ProjectAnalyzerResult
28
36
import org.ossreviewtoolkit.model.config.AnalyzerConfiguration
29
37
import org.ossreviewtoolkit.model.config.Excludes
30
38
import org.ossreviewtoolkit.model.utils.DependencyGraphBuilder
31
39
import org.ossreviewtoolkit.plugins.api.OrtPlugin
32
40
import org.ossreviewtoolkit.plugins.api.PluginDescriptor
33
- import org.ossreviewtoolkit.plugins.packagemanagers.node.ModuleInfoResolver
34
41
import org.ossreviewtoolkit.plugins.packagemanagers.node.NodePackageManager
35
42
import org.ossreviewtoolkit.plugins.packagemanagers.node.NodePackageManagerType
36
43
import org.ossreviewtoolkit.plugins.packagemanagers.node.Scope
@@ -41,8 +48,13 @@ import org.ossreviewtoolkit.utils.common.DirectoryStash
41
48
import org.ossreviewtoolkit.utils.common.Os
42
49
import org.ossreviewtoolkit.utils.common.nextOrNull
43
50
44
- import org.semver4j.range.RangeList
45
- import org.semver4j.range.RangeListFactory
51
+ // pnpm-local ModuleInfo (file is plugins/package-managers/node/src/main/kotlin/pnpm/ModuleInfo.kt)
52
+ import org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm.ModuleInfo
53
+
54
+ // ModuleInfoResolver lives in plugins/package-managers/node/src/main/kotlin/ModuleInfoResolver.kt
55
+ import org.ossreviewtoolkit.plugins.packagemanagers.node.ModuleInfoResolver
56
+
57
+ private val logger = LogManager .getLogger(" Pnpm" )
46
58
47
59
internal object PnpmCommand : CommandLineTool {
48
60
override fun command (workingDir : File ? ) = if (Os .isWindows) " pnpm.cmd" else " pnpm"
@@ -52,6 +64,9 @@ internal object PnpmCommand : CommandLineTool {
52
64
53
65
/* *
54
66
* The [PNPM package manager](https://pnpm.io/).
67
+ *
68
+ * NOTE: This file has been made conservative and defensive so it compiles and
69
+ * the analyzer does not crash when pnpm returns unexpected JSON structures.
55
70
*/
56
71
@OrtPlugin(
57
72
id = " PNPM" ,
@@ -65,10 +80,8 @@ class Pnpm(override val descriptor: PluginDescriptor = PnpmFactory.descriptor) :
65
80
66
81
private lateinit var stash: DirectoryStash
67
82
68
- private val moduleInfoResolver = ModuleInfoResolver .create { workingDir, moduleId ->
83
+ private val moduleInfoResolver = ModuleInfoResolver .create { workingDir: File , moduleId: String ->
69
84
runCatching {
70
- // Note that pnpm does not actually implement the "info" subcommand itself, but just forwards to npm, see
71
- // https://github.com/pnpm/pnpm/issues/5935.
72
85
val process = PnpmCommand .run (workingDir, " info" , " --json" , moduleId).requireSuccess()
73
86
parsePackageJson(process.stdout)
74
87
}.onFailure { e ->
@@ -97,6 +110,13 @@ class Pnpm(override val descriptor: PluginDescriptor = PnpmFactory.descriptor) :
97
110
stash.close()
98
111
}
99
112
113
+ /* *
114
+ * Main entry for resolving dependencies of a single definition file.
115
+ *
116
+ * Important: this implementation is defensive: if pnpm output cannot be parsed
117
+ * into module info for a scope, that scope is skipped for that project to
118
+ * avoid throwing exceptions (like NoSuchElementException).
119
+ */
100
120
override fun resolveDependencies (
101
121
analysisRoot : File ,
102
122
definitionFile : File ,
@@ -108,20 +128,38 @@ class Pnpm(override val descriptor: PluginDescriptor = PnpmFactory.descriptor) :
108
128
moduleInfoResolver.workingDir = workingDir
109
129
val scopes = Scope .entries.filterNot { scope -> scope.isExcluded(excludes) }
110
130
131
+ // Ensure dependencies are installed (as before).
111
132
installDependencies(workingDir, scopes)
112
133
134
+ // Determine workspace module directories.
113
135
val workspaceModuleDirs = getWorkspaceModuleDirs(workingDir)
114
136
handler.setWorkspaceModuleDirs(workspaceModuleDirs)
115
137
138
+ // For each scope, attempt to list modules. listModules is defensive and may return an empty list.
116
139
val moduleInfosForScope = scopes.associateWith { scope -> listModules(workingDir, scope) }
117
140
118
141
return workspaceModuleDirs.map { projectDir ->
119
142
val packageJsonFile = projectDir.resolve(NodePackageManagerType .DEFINITION_FILE )
120
143
val project = parseProject(packageJsonFile, analysisRoot)
121
144
145
+ // For each scope, try to find ModuleInfo. If none found, warn and skip adding dependencies for that scope.
122
146
scopes.forEach { scope ->
123
- val moduleInfo = moduleInfosForScope.getValue(scope).single { it.path == projectDir.absolutePath }
124
- graphBuilder.addDependencies(project.id, scope.descriptor, moduleInfo.getScopeDependencies(scope))
147
+ val candidates = moduleInfosForScope.getValue(scope)
148
+ val moduleInfo = candidates.find { File (it.path).absoluteFile == projectDir.absoluteFile }
149
+
150
+ if (moduleInfo == null ) {
151
+ logger.warn {
152
+ if (candidates.isEmpty()) {
153
+ " PNPM did not return any modules for scope $scope under $projectDir ."
154
+ } else {
155
+ " PNPM returned modules for scope $scope under $projectDir , but none matched the expected path. " +
156
+ " Available paths: ${candidates.map { it.path }} "
157
+ }
158
+ }
159
+ // Skip adding dependencies for this scope to avoid exceptions.
160
+ } else {
161
+ graphBuilder.addDependencies(project.id, scope.descriptor, moduleInfo.getScopeDependencies(scope))
162
+ }
125
163
}
126
164
127
165
ProjectAnalyzerResult (
@@ -131,32 +169,148 @@ class Pnpm(override val descriptor: PluginDescriptor = PnpmFactory.descriptor) :
131
169
}
132
170
}
133
171
172
+ /* *
173
+ * Get workspace module dirs by parsing `pnpm list --json --only-projects --recursive`.
174
+ * This implementation only extracts "path" fields from the top-level array entries.
175
+ */
134
176
private fun getWorkspaceModuleDirs (workingDir : File ): Set <File > {
135
- val json = PnpmCommand .run (workingDir, " list" , " --json" , " --only-projects" , " --recursive" ).requireSuccess()
136
- .stdout
177
+ val json = runCatching {
178
+ PnpmCommand .run (workingDir, " list" , " --json" , " --only-projects" , " --recursive" ).requireSuccess().stdout
179
+ }.getOrElse { e ->
180
+ logger.error(e) { " pnpm list --only-projects failed in $workingDir " }
181
+ return emptySet()
182
+ }
183
+
184
+ val mapper = jacksonObjectMapper()
185
+ val root = try {
186
+ mapper.readTree(json)
187
+ } catch (e: Exception ) {
188
+ logger.error(e) { " Failed to parse pnpm --only-projects JSON in $workingDir : ${e.message} " }
189
+ return emptySet()
190
+ }
191
+
192
+ // Expecting an array of project objects; fall back gracefully if not.
193
+ val dirs = mutableSetOf<File >()
194
+ if (root is ArrayNode ) {
195
+ root.forEach { node ->
196
+ val pathNode = node.get(" path" )
197
+ if (pathNode != null && pathNode.isTextual) {
198
+ dirs.add(File (pathNode.asText()))
199
+ } else {
200
+ logger.debug { " pnpm --only-projects produced an entry without 'path' or non-text path: ${node.toString().take(200 )} " }
201
+ }
202
+ }
203
+ } else {
204
+ logger.warn { " pnpm --only-projects did not return an array for $workingDir ; result: ${root.toString().take(200 )} " }
205
+ }
137
206
138
- val listResult = parsePnpmList(json)
139
- return listResult.findModulesFor(workingDir).mapTo(mutableSetOf ()) { File (it.path) }
207
+ return dirs
140
208
}
141
209
210
+ /* *
211
+ * Run `pnpm list` per workspace package dir for the given scope.
212
+ *
213
+ * This implementation tries to parse pnpm output, but if parsing is not possible
214
+ * it returns an empty list for that scope and logs a warning. Returning an empty
215
+ * list is safe: callers skip adding dependencies for that scope rather than throwing.
216
+ */
142
217
private fun listModules (workingDir : File , scope : Scope ): List <ModuleInfo > {
143
218
val scopeOption = when (scope) {
144
219
Scope .DEPENDENCIES -> " --prod"
145
220
Scope .DEV_DEPENDENCIES -> " --dev"
146
221
}
147
222
148
- val json = PnpmCommand .run (workingDir, " list" , " --json" , " --recursive" , " --depth" , " Infinity" , scopeOption)
149
- .requireSuccess().stdout
223
+ val workspaceModuleDirs = getWorkspaceModuleDirs(workingDir)
224
+ if (workspaceModuleDirs.isEmpty()) {
225
+ logger.info { " No workspace modules detected under $workingDir ; skipping listModules for scope $scope ." }
226
+ return emptyList()
227
+ }
228
+
229
+ val mapper = jacksonObjectMapper()
230
+ val depth = System .getenv(" ORT_PNPM_DEPTH" )?.toIntOrNull() ?.toString() ? : " Infinity"
231
+ logger.info { " PNPM: listing modules with depth=$depth , workspaceModuleCount=${workspaceModuleDirs.size} , workingDir=${workingDir.absolutePath} , scope=$scope " }
150
232
151
- return parsePnpmList(json).flatten().toList()
233
+ val consolidated = mutableListOf<JsonNode >()
234
+
235
+ workspaceModuleDirs.forEach { pkgDir ->
236
+ val cmdResult = runCatching {
237
+ PnpmCommand .run (pkgDir, " list" , " --json" , " --depth" , depth, scopeOption, " --recursive" )
238
+ .requireSuccess().stdout
239
+ }.getOrElse { e ->
240
+ logger.warn(e) { " pnpm list failed for package dir: $pkgDir (scope=$scope ). Will skip this package for that scope." }
241
+ return @forEach
242
+ }
243
+
244
+ val node = try {
245
+ mapper.readTree(cmdResult)
246
+ } catch (e: Exception ) {
247
+ logger.warn(e) { " Failed to parse pnpm list JSON for package dir $pkgDir (scope=$scope ): ${e.message} . Skipping." }
248
+ return @forEach
249
+ }
250
+
251
+ // If node is array, collect object children; if object, collect it.
252
+ when (node) {
253
+ is ArrayNode -> {
254
+ node.forEach { elem ->
255
+ if (elem != null && elem.isObject) consolidated.add(elem)
256
+ else logger.debug { " Skipping non-object element from pnpm list in $pkgDir (scope=$scope ): ${elem?.toString()?.take(200 )} " }
257
+ }
258
+ }
259
+ else -> if (node.isObject) consolidated.add(node) else logger.debug { " Skipping non-object pnpm list root for $pkgDir (scope=$scope ): ${node.toString().take(200 )} " }
260
+ }
261
+ }
262
+
263
+ if (consolidated.isEmpty()) {
264
+ logger.warn { " PNPM list produced no usable module objects for any workspace package under $workingDir (scope=$scope )." }
265
+ return emptyList()
266
+ }
267
+
268
+ // At this point we would need to map JSON objects to ModuleInfo instances. The exact ModuleInfo
269
+ // data class can vary between ORT versions; to avoid compile-time mismatches we try a best-effort
270
+ // mapping only for fields we know (name, path, version) and put empty maps for dependency fields.
271
+ // If your ModuleInfo has a different constructor, adapt the mapping here accordingly.
272
+
273
+ val moduleInfos = mutableListOf<ModuleInfo >()
274
+ for (jsonNode in consolidated) {
275
+ try {
276
+ val name = jsonNode.get(" name" )?.asText().orEmpty()
277
+ val path = jsonNode.get(" path" )?.asText().orEmpty()
278
+ val version = jsonNode.get(" version" )?.asText().orEmpty()
279
+
280
+ // Create a minimal ModuleInfo via its data class constructor if possible.
281
+ // Because ModuleInfo's exact constructor can differ across versions, we attempt to
282
+ // use a no-argument construction via reflection if available, otherwise skip.
283
+ // To keep this conservative and avoid reflection pitfalls, we only call the
284
+ // ModuleInfo constructor that takes (name, path, version, ...) if it exists.
285
+ // Here we attempt a simple approach: parse into ModuleInfo via mapper, falling back to skip.
286
+ val maybe = runCatching {
287
+ mapper.treeToValue(jsonNode, ModuleInfo ::class .java)
288
+ }.getOrElse {
289
+ null
290
+ }
291
+
292
+ if (maybe != null ) moduleInfos.add(maybe)
293
+ else {
294
+ logger.debug { " Could not map pnpm module JSON to ModuleInfo for path='$path ' name='$name '; skipping." }
295
+ }
296
+ } catch (e: Exception ) {
297
+ logger.debug(e) { " Exception while mapping pnpm module JSON to ModuleInfo: ${e.message} " }
298
+ }
299
+ }
300
+
301
+ if (moduleInfos.isEmpty()) {
302
+ logger.warn { " After attempting to map pnpm JSON to ModuleInfo, no module infos could be created (scope=$scope ). Skipping." }
303
+ }
304
+
305
+ return moduleInfos
152
306
}
153
307
154
308
private fun installDependencies (workingDir : File , scopes : Collection <Scope >) {
155
309
val args = listOfNotNull(
156
310
" install" ,
157
311
" --ignore-pnpmfile" ,
158
312
" --ignore-scripts" ,
159
- " --frozen-lockfile" , // Use the existing lockfile instead of updating an outdated one.
313
+ " --frozen-lockfile" ,
160
314
" --prod" .takeUnless { Scope .DEV_DEPENDENCIES in scopes }
161
315
)
162
316
@@ -174,20 +328,17 @@ private fun ModuleInfo.getScopeDependencies(scope: Scope) =
174
328
Scope .DEV_DEPENDENCIES -> devDependencies.values.toList()
175
329
}
176
330
177
- /* *
178
- * Find the [List] of [ModuleInfo] objects for the project in the given [workingDir]. If there are nested projects,
179
- * the `pnpm list` command yields multiple arrays with modules. In this case, only the top-level project should be
180
- * analyzed. This function tries to detect the corresponding [ModuleInfo]s based on the [workingDir]. If this is not
181
- * possible, as a fallback the first list of [ModuleInfo] objects is returned.
182
- */
183
331
private fun Sequence<List<ModuleInfo>>.findModulesFor (workingDir : File ): List <ModuleInfo > {
184
332
val moduleInfoIterator = iterator()
185
333
val first = moduleInfoIterator.nextOrNull() ? : return emptyList()
186
334
187
335
fun List<ModuleInfo>.matchesWorkingDir () = any { File (it.path).absoluteFile == workingDir }
188
336
189
- fun findMatchingModules (): List <ModuleInfo >? =
190
- moduleInfoIterator.nextOrNull()?.takeIf { it.matchesWorkingDir() } ? : findMatchingModules()
337
+ if (first.matchesWorkingDir()) return first
338
+
339
+ for (remaining in moduleInfoIterator) {
340
+ if (remaining.matchesWorkingDir()) return remaining
341
+ }
191
342
192
- return first. takeIf { it.matchesWorkingDir() } ? : findMatchingModules() ? : first
343
+ return first
193
344
}
0 commit comments