Skip to content

Commit 7263bc9

Browse files
authored
fix(nodejs): support pnpm workspaces with overlapping packages (#10894)
1 parent aff9078 commit 7263bc9

4 files changed

Lines changed: 89 additions & 9 deletions

File tree

pkg/dependency/parser/nodejs/pnpm/parse.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,20 @@ func (p *Parser) parseV9(lockFile LockFile) ([]ftypes.Package, []ftypes.Dependen
133133
// The "importers" section contains the dependencies defined in package.json files.
134134
// We need to identify which packages are direct dependencies (vs transitive)
135135
// and which are development dependencies (vs production dependencies).
136-
devDeps := make(map[string]string) // name -> version for dev dependencies
137-
deps := make(map[string]string) // name -> version for production dependencies
136+
//
137+
// Sets hold packageID(name, version) so that every (name, version) pair from
138+
// every importer is recorded independently. Using name alone would cause the
139+
// last importer to silently overwrite earlier ones, producing non-deterministic
140+
// dev/prod classification when multiple workspaces pin different versions of the
141+
// same package.
142+
directDevDeps := set.New[string]() // packageIDs of direct dev dependencies
143+
directProdDeps := set.New[string]() // packageIDs of direct production dependencies
138144
for _, importer := range lockFile.Importers {
139145
for n, v := range importer.DevDependencies {
140-
devDeps[n] = v.Version
146+
directDevDeps.Append(packageID(n, p.trimPeerDeps(v.Version, lockVer)))
141147
}
142148
for n, v := range importer.Dependencies {
143-
deps[n] = v.Version
149+
directProdDeps.Append(packageID(n, p.trimPeerDeps(v.Version, lockVer)))
144150
}
145151
}
146152

@@ -156,7 +162,8 @@ func (p *Parser) parseV9(lockFile LockFile) ([]ftypes.Package, []ftypes.Dependen
156162
// Try to get the exact version from the "packages" section if available.
157163
// The "packages" section may contain more accurate version information
158164
// for packages installed from non-standard sources (git, local files, etc.).
159-
pkgKey := PackageKey(packageID(name, version))
165+
pkgID := packageID(name, version)
166+
pkgKey := PackageKey(pkgID)
160167
if pkgInfo, ok := lockFile.Packages[pkgKey]; ok && pkgInfo.Version != "" {
161168
parsedVer = pkgInfo.Version
162169
}
@@ -167,12 +174,11 @@ func (p *Parser) parseV9(lockFile LockFile) ([]ftypes.Package, []ftypes.Dependen
167174
dev := true
168175
relationship := ftypes.RelationshipIndirect // Assume transitive by default
169176

170-
// Check if this package matches a direct dev dependency
171-
if v, ok := devDeps[name]; ok && p.trimPeerDeps(v, lockVer) == version {
177+
// Check if this package matches a direct dev or production dependency.
178+
if directDevDeps.Contains(pkgID) {
172179
relationship = ftypes.RelationshipDirect
173180
}
174-
// Check if this package matches a direct production dependency
175-
if v, ok := deps[name]; ok && p.trimPeerDeps(v, lockVer) == version {
181+
if directProdDeps.Contains(pkgID) {
176182
relationship = ftypes.RelationshipDirect
177183
dev = false // This is a production dependency, not a dev dependency
178184
}

pkg/dependency/parser/nodejs/pnpm/parse_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ func TestParse(t *testing.T) {
7777
want: pnpmV9MultipleDocuments,
7878
wantDeps: pnpmV9MultipleDocumentsDeps,
7979
},
80+
{
81+
name: "v9 with same package at different versions across importers",
82+
file: "testdata/pnpm-lock_v9_multi_version.yaml",
83+
want: pnpmV9MultiVersion,
84+
wantDeps: pnpmV9MultiVersionDeps,
85+
},
8086
}
8187

8288
for _, tt := range tests {

pkg/dependency/parser/nodejs/pnpm/parse_testcase.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,4 +1090,37 @@ var (
10901090
},
10911091
}
10921092
pnpmV9MultipleDocumentsDeps = []ftypes.Dependency{}
1093+
1094+
// pnpmV9MultiVersion tests that when multiple workspaces depend on the same
1095+
// package at different versions, all versions are reported. This guards against
1096+
// a bug where deps[name]=version (name-only key) caused the last importer
1097+
// iterated in Go's random map order to silently overwrite earlier versions.
1098+
//
1099+
// docker run --rm node@sha256:b46a10d964ad15136ebdf9012142131481caa0697d7a4d4eafe4bbabd818f876 sh -c '
1100+
// npm install -g pnpm@11.9.0
1101+
// mkdir /app && cd /app
1102+
// mkdir app-a app-b
1103+
// cat > pnpm-workspace.yaml <<EOF
1104+
// packages:
1105+
// - app-a
1106+
// - app-b
1107+
// EOF
1108+
// (cd app-a && pnpm init && pnpm add is-number@6.0.0)
1109+
// (cd app-b && pnpm init && pnpm add is-number@7.0.0)
1110+
// cat pnpm-lock.yaml'
1111+
pnpmV9MultiVersion = []ftypes.Package{
1112+
{
1113+
ID: "is-number@6.0.0",
1114+
Name: "is-number",
1115+
Version: "6.0.0",
1116+
Relationship: ftypes.RelationshipDirect,
1117+
},
1118+
{
1119+
ID: "is-number@7.0.0",
1120+
Name: "is-number",
1121+
Version: "7.0.0",
1122+
Relationship: ftypes.RelationshipDirect,
1123+
},
1124+
}
1125+
pnpmV9MultiVersionDeps = []ftypes.Dependency{}
10931126
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
lockfileVersion: '9.0'
2+
3+
settings:
4+
autoInstallPeers: true
5+
excludeLinksFromLockfile: false
6+
7+
importers:
8+
9+
app-a:
10+
dependencies:
11+
is-number:
12+
specifier: 6.0.0
13+
version: 6.0.0
14+
15+
app-b:
16+
dependencies:
17+
is-number:
18+
specifier: 7.0.0
19+
version: 7.0.0
20+
21+
packages:
22+
23+
is-number@6.0.0:
24+
resolution: {integrity: sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==}
25+
engines: {node: '>=0.10.0'}
26+
27+
is-number@7.0.0:
28+
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
29+
engines: {node: '>=0.12.0'}
30+
31+
snapshots:
32+
33+
is-number@6.0.0: {}
34+
35+
is-number@7.0.0: {}

0 commit comments

Comments
 (0)