Skip to content

Commit af35e43

Browse files
oheger-boschsschuberth
authored andcommitted
fix(pnpm): Fix parsing of JSON output for nested projects
If PNPM encounters nested projects, the output of the `list` command is not well-formed JSON, but consists of multiple arrays. Change the `parsePnpmList()` function to handle this format correctly. In `Pnpm`, only analyze the top-level project, since nested projects will be handled by the `pnpm` command transparently. Fixes #9784. Signed-off-by: Oliver Heger <oliver.heger@bosch.io>
1 parent c7277ab commit af35e43

File tree

5 files changed

+193
-3
lines changed

5 files changed

+193
-3
lines changed

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,27 @@
1919

2020
package org.ossreviewtoolkit.plugins.packagemanagers.node.pnpm
2121

22+
import java.io.ByteArrayInputStream
23+
2224
import kotlinx.serialization.Serializable
25+
import kotlinx.serialization.json.DecodeSequenceMode
2326
import kotlinx.serialization.json.Json
27+
import kotlinx.serialization.json.decodeToSequence
2428

2529
private val JSON = Json { ignoreUnknownKeys = true }
2630

27-
internal fun parsePnpmList(json: String): List<ModuleInfo> = JSON.decodeFromString(json)
31+
/**
32+
* Parse the given [json] output of a PNPM list command. Normally, the resulting [Sequence] contains only a single
33+
* [List] with the [ModuleInfo] objects of the project. If there are nested projects, PNPM outputs multiple arrays,
34+
* which leads to syntactically invalid JSON. This is handled by this function by returning a [Sequence] with a
35+
* corresponding number of elements. In this case, callers are responsible for correctly mapping the elements to
36+
* projects.
37+
*/
38+
internal fun parsePnpmList(json: String): Sequence<List<ModuleInfo>> =
39+
JSON.decodeToSequence<List<ModuleInfo>>(
40+
ByteArrayInputStream(json.toByteArray()),
41+
DecodeSequenceMode.WHITESPACE_SEPARATED
42+
)
2843

2944
@Serializable
3045
data class ModuleInfo(

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import org.ossreviewtoolkit.plugins.packagemanagers.node.parsePackageJson
3535
import org.ossreviewtoolkit.utils.common.CommandLineTool
3636
import org.ossreviewtoolkit.utils.common.DirectoryStash
3737
import org.ossreviewtoolkit.utils.common.Os
38+
import org.ossreviewtoolkit.utils.common.nextOrNull
3839

3940
import org.semver4j.RangesList
4041
import org.semver4j.RangesListFactory
@@ -117,7 +118,8 @@ class Pnpm(
117118
val json = PnpmCommand.run(workingDir, "list", "--json", "--only-projects", "--recursive").requireSuccess()
118119
.stdout
119120

120-
return parsePnpmList(json).mapTo(mutableSetOf()) { File(it.path) }
121+
val listResult = parsePnpmList(json)
122+
return listResult.findModulesFor(workingDir).mapTo(mutableSetOf()) { File(it.path) }
121123
}
122124

123125
private fun listModules(workingDir: File, scope: Scope): List<ModuleInfo> {
@@ -129,7 +131,7 @@ class Pnpm(
129131
val json = PnpmCommand.run(workingDir, "list", "--json", "--recursive", "--depth", "Infinity", scopeOption)
130132
.requireSuccess().stdout
131133

132-
return parsePnpmList(json)
134+
return parsePnpmList(json).flatten().toList()
133135
}
134136

135137
private fun installDependencies(workingDir: File) =
@@ -170,3 +172,21 @@ private fun ModuleInfo.getScopeDependencies(scope: Scope) =
170172

171173
Scope.DEV_DEPENDENCIES -> devDependencies.values.toList()
172174
}
175+
176+
/**
177+
* Find the [List] of [ModuleInfo] objects for the project in the given [workingDir]. If there are nested projects,
178+
* the `pnpm list` command yields multiple arrays with modules. In this case, only the top-level project should be
179+
* analyzed. This function tries to detect the corresponding [ModuleInfo]s based on the [workingDir]. If this is not
180+
* possible, as a fallback the first list of [ModuleInfo] objects is returned.
181+
*/
182+
private fun Sequence<List<ModuleInfo>>.findModulesFor(workingDir: File): List<ModuleInfo> {
183+
val moduleInfoIterator = iterator()
184+
val first = moduleInfoIterator.nextOrNull() ?: return emptyList()
185+
186+
fun List<ModuleInfo>.matchesWorkingDir() = any { File(it.path).absoluteFile == workingDir }
187+
188+
fun findMatchingModules(): List<ModuleInfo>? =
189+
moduleInfoIterator.nextOrNull()?.takeIf { it.matchesWorkingDir() } ?: findMatchingModules()
190+
191+
return first.takeIf { it.matchesWorkingDir() } ?: findMatchingModules() ?: first
192+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[
2+
{
3+
"name": "some-project",
4+
"version": "1.0.0",
5+
"path": "/tmp/work/root",
6+
"private": false,
7+
"dependencies": {
8+
"eslint-scope": {
9+
"from": "eslint-scope",
10+
"version": "link:node_modules/.pnpm/eslint-scope@5.1.1/node_modules/eslint-scope",
11+
"path": "/tmp/work/root/node_modules/.pnpm/eslint-scope@5.1.1/node_modules/eslint-scope"
12+
}
13+
}
14+
},
15+
{
16+
"name": "other-project",
17+
"version": "1.0.1",
18+
"path": "/tmp/work/other_root",
19+
"private": false,
20+
"dependencies": {
21+
"@types/eslint": {
22+
"from": "@types/eslint",
23+
"version": "link:node_modules/.pnpm/@types+eslint@8.56.2/node_modules/@types/eslint",
24+
"path": "/tmp/work/other_root/node_modules/.pnpm/@types+eslint@8.56.2/node_modules/@types/eslint"
25+
}
26+
}
27+
}
28+
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[
2+
{
3+
"name": "outer-project",
4+
"version": "1.0.0",
5+
"path": "/tmp/work/top",
6+
"private": false,
7+
"dependencies": {
8+
"eslint-scope": {
9+
"from": "eslint-scope",
10+
"version": "link:node_modules/.pnpm/eslint-scope@5.1.1/node_modules/eslint-scope",
11+
"path": "/tmp/work/top/node_modules/.pnpm/eslint-scope@5.1.1/node_modules/eslint-scope"
12+
}
13+
}
14+
}
15+
]
16+
17+
[
18+
{
19+
"name": "nested-project",
20+
"version": "1.0.0",
21+
"path": "/tmp/work/top/nested",
22+
"private": false
23+
}
24+
]
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright (C) 2025 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 io.kotest.core.spec.style.WordSpec
23+
import io.kotest.matchers.sequences.shouldContainExactly
24+
25+
import java.io.File
26+
27+
class ModuleInfoTest : WordSpec({
28+
"parsePnpmList()" should {
29+
"handle normal PNPM output" {
30+
val input = File("src/test/assets/pnpm-list.json").readText()
31+
32+
val expectedResults = sequenceOf(
33+
listOf(
34+
ModuleInfo(
35+
name = "some-project",
36+
version = "1.0.0",
37+
path = "/tmp/work/root",
38+
private = false,
39+
dependencies = mapOf(
40+
"eslint-scope" to ModuleInfo.Dependency(
41+
from = "eslint-scope",
42+
version = "link:node_modules/.pnpm/eslint-scope@5.1.1/node_modules/eslint-scope",
43+
path = "/tmp/work/root/node_modules/.pnpm/eslint-scope@5.1.1/node_modules/eslint-scope"
44+
)
45+
)
46+
),
47+
ModuleInfo(
48+
name = "other-project",
49+
version = "1.0.1",
50+
path = "/tmp/work/other_root",
51+
private = false,
52+
dependencies = mapOf(
53+
"@types/eslint" to ModuleInfo.Dependency(
54+
from = "@types/eslint",
55+
version = "link:node_modules/.pnpm/@types+eslint@8.56.2/node_modules/@types/eslint",
56+
path = "/tmp/work/other_root/node_modules/.pnpm/@types+eslint@8.56.2" +
57+
"/node_modules/@types/eslint"
58+
)
59+
)
60+
)
61+
)
62+
)
63+
64+
val moduleInfos = parsePnpmList(input)
65+
66+
moduleInfos shouldContainExactly expectedResults
67+
}
68+
69+
"handle multiple JSON arrays" {
70+
val input = File("src/test/assets/pnpm-multi-list.json").readText()
71+
72+
val expectedResults = sequenceOf(
73+
listOf(
74+
ModuleInfo(
75+
name = "outer-project",
76+
version = "1.0.0",
77+
path = "/tmp/work/top",
78+
private = false,
79+
dependencies = mapOf(
80+
"eslint-scope" to ModuleInfo.Dependency(
81+
from = "eslint-scope",
82+
version = "link:node_modules/.pnpm/eslint-scope@5.1.1/node_modules/eslint-scope",
83+
path = "/tmp/work/top/node_modules/.pnpm/eslint-scope@5.1.1/node_modules/eslint-scope"
84+
)
85+
)
86+
)
87+
),
88+
listOf(
89+
ModuleInfo(
90+
name = "nested-project",
91+
version = "1.0.0",
92+
path = "/tmp/work/top/nested",
93+
private = false
94+
)
95+
)
96+
)
97+
98+
val moduleInfos = parsePnpmList(input)
99+
100+
moduleInfos shouldContainExactly expectedResults
101+
}
102+
}
103+
})

0 commit comments

Comments
 (0)