pnpm-dedup 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +218 -0
  3. package/dist/applyClusterFixes.js +252 -0
  4. package/dist/applyClusterFixes.js.map +1 -0
  5. package/dist/applyClusterFixes.test.js +474 -0
  6. package/dist/applyClusterFixes.test.js.map +1 -0
  7. package/dist/bin/pnpm-dedupe.js +35 -0
  8. package/dist/bin/pnpm-dedupe.js.map +1 -0
  9. package/dist/bin/pnpm-why-duplicate.js +23 -0
  10. package/dist/bin/pnpm-why-duplicate.js.map +1 -0
  11. package/dist/bin/pnpm-why-duplicate.test.js +57 -0
  12. package/dist/bin/pnpm-why-duplicate.test.js.map +1 -0
  13. package/dist/dedupe.js +86 -0
  14. package/dist/dedupe.js.map +1 -0
  15. package/dist/dedupeCheckConsistency.test.js +149 -0
  16. package/dist/dedupeCheckConsistency.test.js.map +1 -0
  17. package/dist/dedupeEndToEnd.test.js +121 -0
  18. package/dist/dedupeEndToEnd.test.js.map +1 -0
  19. package/dist/dedupePeers.test.js +105 -0
  20. package/dist/dedupePeers.test.js.map +1 -0
  21. package/dist/definitions/applyClusterFixes.d.ts +24 -0
  22. package/dist/definitions/applyClusterFixes.d.ts.map +1 -0
  23. package/dist/definitions/applyClusterFixes.test.d.ts +2 -0
  24. package/dist/definitions/applyClusterFixes.test.d.ts.map +1 -0
  25. package/dist/definitions/bin/pnpm-dedupe.d.ts +3 -0
  26. package/dist/definitions/bin/pnpm-dedupe.d.ts.map +1 -0
  27. package/dist/definitions/bin/pnpm-why-duplicate.d.ts +3 -0
  28. package/dist/definitions/bin/pnpm-why-duplicate.d.ts.map +1 -0
  29. package/dist/definitions/bin/pnpm-why-duplicate.test.d.ts +2 -0
  30. package/dist/definitions/bin/pnpm-why-duplicate.test.d.ts.map +1 -0
  31. package/dist/definitions/dedupe.d.ts +15 -0
  32. package/dist/definitions/dedupe.d.ts.map +1 -0
  33. package/dist/definitions/dedupeCheckConsistency.test.d.ts +2 -0
  34. package/dist/definitions/dedupeCheckConsistency.test.d.ts.map +1 -0
  35. package/dist/definitions/dedupeEndToEnd.test.d.ts +2 -0
  36. package/dist/definitions/dedupeEndToEnd.test.d.ts.map +1 -0
  37. package/dist/definitions/dedupePeers.test.d.ts +2 -0
  38. package/dist/definitions/dedupePeers.test.d.ts.map +1 -0
  39. package/dist/definitions/displayMany.d.ts +16 -0
  40. package/dist/definitions/displayMany.d.ts.map +1 -0
  41. package/dist/definitions/displayMany.test.d.ts +2 -0
  42. package/dist/definitions/displayMany.test.d.ts.map +1 -0
  43. package/dist/definitions/helpers/buildIdentifiedFixesMap.test.d.ts +2 -0
  44. package/dist/definitions/helpers/buildIdentifiedFixesMap.test.d.ts.map +1 -0
  45. package/dist/definitions/helpers/buildPnpmPackagesMap.d.ts +13 -0
  46. package/dist/definitions/helpers/buildPnpmPackagesMap.d.ts.map +1 -0
  47. package/dist/definitions/helpers/buildPnpmPackagesMap.test.d.ts +2 -0
  48. package/dist/definitions/helpers/buildPnpmPackagesMap.test.d.ts.map +1 -0
  49. package/dist/definitions/helpers/collectDependentRanges.d.ts +22 -0
  50. package/dist/definitions/helpers/collectDependentRanges.d.ts.map +1 -0
  51. package/dist/definitions/helpers/collectDependentRanges.test.d.ts +2 -0
  52. package/dist/definitions/helpers/collectDependentRanges.test.d.ts.map +1 -0
  53. package/dist/definitions/helpers/collectPnpmDependents.d.ts +8 -0
  54. package/dist/definitions/helpers/collectPnpmDependents.d.ts.map +1 -0
  55. package/dist/definitions/helpers/collectPnpmDependents.test.d.ts +2 -0
  56. package/dist/definitions/helpers/collectPnpmDependents.test.d.ts.map +1 -0
  57. package/dist/definitions/helpers/duplicateSnapshot.d.ts +3 -0
  58. package/dist/definitions/helpers/duplicateSnapshot.d.ts.map +1 -0
  59. package/dist/definitions/helpers/parsePnpmLockPackages.d.ts +44 -0
  60. package/dist/definitions/helpers/parsePnpmLockPackages.d.ts.map +1 -0
  61. package/dist/definitions/helpers/parsePnpmLockPackages.test.d.ts +2 -0
  62. package/dist/definitions/helpers/parsePnpmLockPackages.test.d.ts.map +1 -0
  63. package/dist/definitions/helpers/pnpmProtocol.d.ts +2 -0
  64. package/dist/definitions/helpers/pnpmProtocol.d.ts.map +1 -0
  65. package/dist/definitions/helpers/pnpmVersion.d.ts +11 -0
  66. package/dist/definitions/helpers/pnpmVersion.d.ts.map +1 -0
  67. package/dist/definitions/helpers/pnpmWorkspaceYaml.d.ts +23 -0
  68. package/dist/definitions/helpers/pnpmWorkspaceYaml.d.ts.map +1 -0
  69. package/dist/definitions/helpers/pnpmWorkspaceYaml.test.d.ts +2 -0
  70. package/dist/definitions/helpers/pnpmWorkspaceYaml.test.d.ts.map +1 -0
  71. package/dist/definitions/helpers/projectDir.d.ts +3 -0
  72. package/dist/definitions/helpers/projectDir.d.ts.map +1 -0
  73. package/dist/definitions/helpers/readInstalledManifest.d.ts +32 -0
  74. package/dist/definitions/helpers/readInstalledManifest.d.ts.map +1 -0
  75. package/dist/definitions/helpers/readInstalledManifest.test.d.ts +2 -0
  76. package/dist/definitions/helpers/readInstalledManifest.test.d.ts.map +1 -0
  77. package/dist/definitions/helpers/runPnpm.d.ts +11 -0
  78. package/dist/definitions/helpers/runPnpm.d.ts.map +1 -0
  79. package/dist/definitions/helpers/tempProjects.d.ts +8 -0
  80. package/dist/definitions/helpers/tempProjects.d.ts.map +1 -0
  81. package/dist/definitions/helpers/toLockstepGraph.d.ts +12 -0
  82. package/dist/definitions/helpers/toLockstepGraph.d.ts.map +1 -0
  83. package/dist/definitions/helpers/toLockstepGraph.test.d.ts +2 -0
  84. package/dist/definitions/helpers/toLockstepGraph.test.d.ts.map +1 -0
  85. package/dist/definitions/helpers/versionsSnapshot.d.ts +3 -0
  86. package/dist/definitions/helpers/versionsSnapshot.d.ts.map +1 -0
  87. package/dist/definitions/identifyClusterFixes.d.ts +7 -0
  88. package/dist/definitions/identifyClusterFixes.d.ts.map +1 -0
  89. package/dist/definitions/identifyClusterFixes.test.d.ts +2 -0
  90. package/dist/definitions/identifyClusterFixes.test.d.ts.map +1 -0
  91. package/dist/definitions/identifyResolutionFixes.test.d.ts +2 -0
  92. package/dist/definitions/identifyResolutionFixes.test.d.ts.map +1 -0
  93. package/dist/definitions/index.d.ts +25 -0
  94. package/dist/definitions/index.d.ts.map +1 -0
  95. package/dist/definitions/pnpmLockTypes.d.ts +42 -0
  96. package/dist/definitions/pnpmLockTypes.d.ts.map +1 -0
  97. package/dist/definitions/readPnpmLock.d.ts +3 -0
  98. package/dist/definitions/readPnpmLock.d.ts.map +1 -0
  99. package/dist/displayMany.js +56 -0
  100. package/dist/displayMany.js.map +1 -0
  101. package/dist/displayMany.test.js +146 -0
  102. package/dist/displayMany.test.js.map +1 -0
  103. package/dist/helpers/buildIdentifiedFixesMap.test.js +27 -0
  104. package/dist/helpers/buildIdentifiedFixesMap.test.js.map +1 -0
  105. package/dist/helpers/buildPnpmPackagesMap.js +23 -0
  106. package/dist/helpers/buildPnpmPackagesMap.js.map +1 -0
  107. package/dist/helpers/buildPnpmPackagesMap.test.js +68 -0
  108. package/dist/helpers/buildPnpmPackagesMap.test.js.map +1 -0
  109. package/dist/helpers/collectDependentRanges.js +118 -0
  110. package/dist/helpers/collectDependentRanges.js.map +1 -0
  111. package/dist/helpers/collectDependentRanges.test.js +211 -0
  112. package/dist/helpers/collectDependentRanges.test.js.map +1 -0
  113. package/dist/helpers/collectPnpmDependents.js +55 -0
  114. package/dist/helpers/collectPnpmDependents.js.map +1 -0
  115. package/dist/helpers/collectPnpmDependents.test.js +78 -0
  116. package/dist/helpers/collectPnpmDependents.test.js.map +1 -0
  117. package/dist/helpers/duplicateSnapshot.js +5 -0
  118. package/dist/helpers/duplicateSnapshot.js.map +1 -0
  119. package/dist/helpers/parsePnpmLockPackages.js +62 -0
  120. package/dist/helpers/parsePnpmLockPackages.js.map +1 -0
  121. package/dist/helpers/parsePnpmLockPackages.test.js +62 -0
  122. package/dist/helpers/parsePnpmLockPackages.test.js.map +1 -0
  123. package/dist/helpers/pnpmProtocol.js +2 -0
  124. package/dist/helpers/pnpmProtocol.js.map +1 -0
  125. package/dist/helpers/pnpmVersion.js +23 -0
  126. package/dist/helpers/pnpmVersion.js.map +1 -0
  127. package/dist/helpers/pnpmWorkspaceYaml.js +48 -0
  128. package/dist/helpers/pnpmWorkspaceYaml.js.map +1 -0
  129. package/dist/helpers/pnpmWorkspaceYaml.test.js +91 -0
  130. package/dist/helpers/pnpmWorkspaceYaml.test.js.map +1 -0
  131. package/dist/helpers/projectDir.js +10 -0
  132. package/dist/helpers/projectDir.js.map +1 -0
  133. package/dist/helpers/readInstalledManifest.js +139 -0
  134. package/dist/helpers/readInstalledManifest.js.map +1 -0
  135. package/dist/helpers/readInstalledManifest.test.js +83 -0
  136. package/dist/helpers/readInstalledManifest.test.js.map +1 -0
  137. package/dist/helpers/runPnpm.js +16 -0
  138. package/dist/helpers/runPnpm.js.map +1 -0
  139. package/dist/helpers/tempProjects.js +85 -0
  140. package/dist/helpers/tempProjects.js.map +1 -0
  141. package/dist/helpers/toLockstepGraph.js +34 -0
  142. package/dist/helpers/toLockstepGraph.js.map +1 -0
  143. package/dist/helpers/toLockstepGraph.test.js +81 -0
  144. package/dist/helpers/toLockstepGraph.test.js.map +1 -0
  145. package/dist/helpers/versionsSnapshot.js +5 -0
  146. package/dist/helpers/versionsSnapshot.js.map +1 -0
  147. package/dist/identifyClusterFixes.js +53 -0
  148. package/dist/identifyClusterFixes.js.map +1 -0
  149. package/dist/identifyClusterFixes.test.js +307 -0
  150. package/dist/identifyClusterFixes.test.js.map +1 -0
  151. package/dist/identifyResolutionFixes.test.js +49 -0
  152. package/dist/identifyResolutionFixes.test.js.map +1 -0
  153. package/dist/index.js +86 -0
  154. package/dist/index.js.map +1 -0
  155. package/dist/pnpmLockTypes.js +7 -0
  156. package/dist/pnpmLockTypes.js.map +1 -0
  157. package/dist/readPnpmLock.js +6 -0
  158. package/dist/readPnpmLock.js.map +1 -0
  159. package/package.json +71 -0
  160. package/src/applyClusterFixes.test.ts +560 -0
  161. package/src/applyClusterFixes.ts +466 -0
  162. package/src/bin/pnpm-dedupe.ts +44 -0
  163. package/src/bin/pnpm-why-duplicate.test.ts +79 -0
  164. package/src/bin/pnpm-why-duplicate.ts +34 -0
  165. package/src/dedupe.ts +124 -0
  166. package/src/dedupeCheckConsistency.test.ts +218 -0
  167. package/src/dedupeEndToEnd.test.ts +168 -0
  168. package/src/dedupePeers.test.ts +175 -0
  169. package/src/displayMany.test.ts +208 -0
  170. package/src/displayMany.ts +104 -0
  171. package/src/helpers/buildIdentifiedFixesMap.test.ts +33 -0
  172. package/src/helpers/buildPnpmPackagesMap.test.ts +119 -0
  173. package/src/helpers/buildPnpmPackagesMap.ts +53 -0
  174. package/src/helpers/collectDependentRanges.test.ts +311 -0
  175. package/src/helpers/collectDependentRanges.ts +185 -0
  176. package/src/helpers/collectPnpmDependents.test.ts +112 -0
  177. package/src/helpers/collectPnpmDependents.ts +79 -0
  178. package/src/helpers/duplicateSnapshot.ts +18 -0
  179. package/src/helpers/parsePnpmLockPackages.test.ts +93 -0
  180. package/src/helpers/parsePnpmLockPackages.ts +99 -0
  181. package/src/helpers/pnpmProtocol.ts +12 -0
  182. package/src/helpers/pnpmVersion.ts +28 -0
  183. package/src/helpers/pnpmWorkspaceYaml.test.ts +140 -0
  184. package/src/helpers/pnpmWorkspaceYaml.ts +77 -0
  185. package/src/helpers/projectDir.ts +14 -0
  186. package/src/helpers/readInstalledManifest.test.ts +124 -0
  187. package/src/helpers/readInstalledManifest.ts +202 -0
  188. package/src/helpers/runPnpm.ts +34 -0
  189. package/src/helpers/tempProjects.ts +94 -0
  190. package/src/helpers/toLockstepGraph.test.ts +98 -0
  191. package/src/helpers/toLockstepGraph.ts +52 -0
  192. package/src/helpers/versionsSnapshot.ts +9 -0
  193. package/src/identifyClusterFixes.test.ts +430 -0
  194. package/src/identifyClusterFixes.ts +114 -0
  195. package/src/identifyResolutionFixes.test.ts +82 -0
  196. package/src/index.ts +159 -0
  197. package/src/pnpmLockTypes.ts +44 -0
  198. package/src/readPnpmLock.ts +7 -0
@@ -0,0 +1,311 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual, strictEqual } from "node:assert/strict";
3
+ import { fileURLToPath } from "node:url";
4
+ import { readPnpmLock } from "../readPnpmLock.ts";
5
+ import { collectDependentRanges } from "./collectDependentRanges.ts";
6
+ import type { ManifestReader } from "./readInstalledManifest.ts";
7
+ import { createManifestReader } from "./readInstalledManifest.ts";
8
+
9
+ const fixturesBase = (rel: string) =>
10
+ fileURLToPath(new URL(rel, import.meta.url));
11
+
12
+ const lockFor = (scenario: string) =>
13
+ readPnpmLock(fixturesBase(`../../test/fixtures/${scenario}/pnpm-lock.yaml`));
14
+
15
+ const rangesFor = (
16
+ scenario: string,
17
+ packageNames: string[],
18
+ readManifest: ManifestReader = () => undefined,
19
+ ) =>
20
+ collectDependentRanges(
21
+ lockFor(scenario),
22
+ new Set(packageNames),
23
+ readManifest,
24
+ );
25
+
26
+ // In `wildcard-not-reused`, `@tamagui/metro-plugin@1.139.4` declares
27
+ // `metro-config: "*"` and `metro-transform-worker: "*"`. The lockfile only
28
+ // stores the version pnpm picked (0.87.0), so the wildcard is only visible
29
+ // through the installed manifest.
30
+ const wildcardScenario = "wildcard-not-reused";
31
+ const wildcardPackages = ["metro-config", "metro-transform-worker"];
32
+ const wildcardDependent = "@tamagui/metro-plugin@1.139.4";
33
+
34
+ const readWildcardManifest: ManifestReader = (name, version) =>
35
+ name === "@tamagui/metro-plugin" && version === "1.139.4"
36
+ ? { dependencies: { "metro-config": "*", "metro-transform-worker": "*" } }
37
+ : undefined;
38
+
39
+ // `exact-pin-forces-downgrade` commits its virtual store, so the ranges come
40
+ // from the real reader: `@yudiel/react-qr-scanner` pins `barcode-detector`
41
+ // exactly at 3.0.3 while `expo-camera` declares `^3.0.0` and resolved 3.2.2.
42
+ const downgradeScenario = "exact-pin-forces-downgrade";
43
+ const readDowngradeManifest = createManifestReader(
44
+ fixturesBase(`../../test/fixtures/${downgradeScenario}`),
45
+ );
46
+
47
+ const rangeOf = (
48
+ ranges: ReturnType<typeof collectDependentRanges>,
49
+ packageName: string,
50
+ dependentKey: string,
51
+ ): string | undefined =>
52
+ ranges.get(packageName)?.find((dependent) => dependent.key === dependentKey)
53
+ ?.range;
54
+
55
+ describe("collectDependentRanges", () => {
56
+ it("takes a direct dependency's range from the importer specifier", () => {
57
+ deepStrictEqual(rangesFor("simple", ["semver"]).get("semver"), [
58
+ {
59
+ key: "package.json in dependencies",
60
+ range: "7.7.3",
61
+ resolvedVersion: "7.7.3",
62
+ workspace: { path: ".", depType: "dependencies" },
63
+ },
64
+ ]);
65
+ });
66
+
67
+ it("ignores packages that are not in the requested set", () => {
68
+ deepStrictEqual(
69
+ [...rangesFor("duplicated-babel-frame", ["@babel/code-frame"]).keys()],
70
+ ["@babel/code-frame"],
71
+ );
72
+ });
73
+
74
+ it("reads a transitive dependent's declared range from its manifest", () => {
75
+ const ranges = rangesFor(
76
+ wildcardScenario,
77
+ wildcardPackages,
78
+ readWildcardManifest,
79
+ );
80
+ for (const name of wildcardPackages) {
81
+ strictEqual(rangeOf(ranges, name, wildcardDependent), "*");
82
+ }
83
+ });
84
+
85
+ it("falls back to the resolved version when the manifest is unavailable", () => {
86
+ const ranges = rangesFor(wildcardScenario, wildcardPackages);
87
+ for (const name of wildcardPackages) {
88
+ strictEqual(rangeOf(ranges, name, wildcardDependent), "0.87.0");
89
+ }
90
+ });
91
+
92
+ // pnpm folds a resolved peer into the snapshot's `dependencies`, so the edge is
93
+ // there — but only as the version it resolved to. The range stays in the
94
+ // `packages:` entry, and read from there a peer constraint needs no
95
+ // `node_modules`: without it every one of these read as an exact pin of 10.5.0,
96
+ // and no version could satisfy them all.
97
+ it("reads a peer range from the lockfile when no manifest is available", () => {
98
+ const ranges = rangesFor("duplicated-typescript-eslint", ["eslint"]);
99
+
100
+ strictEqual(
101
+ rangeOf(ranges, "eslint", "@eslint-community/eslint-utils@4.9.1"),
102
+ "^6.0.0 || ^7.0.0 || >=8.0.0",
103
+ );
104
+ strictEqual(
105
+ rangeOf(ranges, "eslint", "@typescript-eslint/eslint-plugin@8.61.0"),
106
+ "^8.57.0 || ^9.0.0 || ^10.0.0",
107
+ );
108
+ // declared optional, and kept: pnpm only writes the edge once something
109
+ // provides the peer, so an edge that exists is a peer that was provided
110
+ strictEqual(rangeOf(ranges, "eslint", "@eslint/js@10.0.1"), "^10.0.0");
111
+ });
112
+
113
+ // pnpm resolves a peer like any other edge, so nothing in the snapshot tells a
114
+ // peer requester apart from one holding a copy of its own — the report has to
115
+ it("marks a dependent whose range came from a peer declaration", () => {
116
+ const dependents =
117
+ rangesFor("duplicated-typescript-eslint", ["eslint"]).get("eslint") ?? [];
118
+
119
+ strictEqual(
120
+ dependents.find(
121
+ (dependent) => dependent.key === "eslint-plugin-n@17.24.0",
122
+ )?.peer,
123
+ true,
124
+ );
125
+ // a real dependency edge onto the same package stays unmarked
126
+ strictEqual(
127
+ rangesFor("duplicated-typescript-eslint", ["@eslint/plugin-kit"])
128
+ .get("@eslint/plugin-kit")
129
+ ?.find((dependent) => dependent.key === "eslint@10.5.0")?.peer,
130
+ undefined,
131
+ );
132
+ });
133
+
134
+ // the manifest is read first, so a name a package declares as both a
135
+ // dependency and a peer keeps the dependency's range
136
+ it("prefers a manifest declaration over the lockfile peer block", () => {
137
+ strictEqual(
138
+ rangeOf(
139
+ rangesFor("duplicated-typescript-eslint", ["eslint"], (name, version) =>
140
+ name === "@eslint-community/eslint-utils" && version === "4.9.1"
141
+ ? { dependencies: { eslint: "^10.1.0" } }
142
+ : undefined,
143
+ ),
144
+ "eslint",
145
+ "@eslint-community/eslint-utils@4.9.1",
146
+ ),
147
+ "^10.1.0",
148
+ );
149
+ });
150
+
151
+ it("separates a caret dependent's range from the version it resolved to", () => {
152
+ const ranges = rangesFor(
153
+ downgradeScenario,
154
+ ["barcode-detector"],
155
+ readDowngradeManifest,
156
+ );
157
+
158
+ deepStrictEqual(ranges.get("barcode-detector"), [
159
+ {
160
+ key: "@yudiel/react-qr-scanner@2.3.1",
161
+ range: "3.0.3",
162
+ resolvedVersion: "3.0.3",
163
+ requesterName: "@yudiel/react-qr-scanner",
164
+ },
165
+ {
166
+ key: "expo-camera@57.0.3",
167
+ range: "^3.0.0",
168
+ resolvedVersion: "3.2.2",
169
+ requesterName: "expo-camera",
170
+ },
171
+ ]);
172
+ });
173
+
174
+ // `aliased-swapped-names`: the importer reaches the real `typescript` through
175
+ // the `@typescript/native` key and hands the `typescript` key to another
176
+ // package. The dependent has to be filed under the npm name the specifier
177
+ // targets, with the range and the version the alias resolved to, and the key
178
+ // it was declared under — that key is what makes the declaration binding
179
+ // rather than a range the dedupe may rewrite.
180
+ it("resolves an importer alias whose key names another package", () => {
181
+ const ranges = rangesFor("aliased-swapped-names", [
182
+ "typescript",
183
+ "@typescript/typescript6",
184
+ ]);
185
+
186
+ deepStrictEqual(ranges.get("typescript"), [
187
+ {
188
+ key: "package.json in devDependencies",
189
+ range: "7.0.2",
190
+ resolvedVersion: "7.0.2",
191
+ aliasKey: "@typescript/native",
192
+ workspace: { path: ".", depType: "devDependencies" },
193
+ },
194
+ {
195
+ key: "tool@1.0.0",
196
+ range: "5.9.3",
197
+ resolvedVersion: "5.9.3",
198
+ requesterName: "tool",
199
+ },
200
+ ]);
201
+
202
+ deepStrictEqual(ranges.get("@typescript/typescript6"), [
203
+ {
204
+ key: "package.json in devDependencies",
205
+ range: "6.0.2",
206
+ resolvedVersion: "6.0.2",
207
+ aliasKey: "typescript",
208
+ workspace: { path: ".", depType: "devDependencies" },
209
+ },
210
+ ]);
211
+ });
212
+
213
+ // pnpm writes an aliased importer's `version` as `realName@version`, which is
214
+ // not a version: carrying it through would put a string no semver call accepts
215
+ // where the resolved version belongs.
216
+ it("strips the alias target from an importer's resolved version", () => {
217
+ deepStrictEqual(
218
+ rangesFor("mergeable-alias", ["printable-shell-command"])
219
+ .get("printable-shell-command")
220
+ ?.filter((dependent) => dependent.workspace !== undefined),
221
+ [
222
+ {
223
+ key: "package.json in devDependencies",
224
+ range: "^5.0.0",
225
+ resolvedVersion: "5.3.1",
226
+ workspace: { path: ".", depType: "devDependencies" },
227
+ },
228
+ {
229
+ key: "package.json in devDependencies",
230
+ range: "5.0.7",
231
+ resolvedVersion: "5.0.7",
232
+ aliasKey: "printable-shell-command-pinned",
233
+ workspace: { path: ".", depType: "devDependencies" },
234
+ },
235
+ ],
236
+ );
237
+ });
238
+
239
+ it("keeps the exact ranges a package declares for its own family", () => {
240
+ const ranges = rangesFor(
241
+ wildcardScenario,
242
+ wildcardPackages,
243
+ readWildcardManifest,
244
+ );
245
+ for (const name of wildcardPackages) {
246
+ strictEqual(rangeOf(ranges, name, "metro@0.84.5"), "0.84.5");
247
+ strictEqual(rangeOf(ranges, name, "metro@0.87.0"), "0.87.0");
248
+ }
249
+ });
250
+
251
+ // A `workspace:`, `catalog:`, `file:` or `link:` specifier names something
252
+ // other than the npm package sharing its key, and a dist-tag carries no
253
+ // comparable range. Both would read as unsatisfiable ranges rather than as
254
+ // "no constraint", which suppresses merges the real dependents allow.
255
+ it("leaves out importer specifiers that are not npm ranges", () => {
256
+ const ranges = rangesFor("protocol-dependents", [
257
+ "helper",
258
+ "semver",
259
+ "picocolors",
260
+ "left-pad",
261
+ "kleur",
262
+ "chalk",
263
+ ]);
264
+
265
+ // only the snapshot dependent of `app` survives for semver; the importer
266
+ // declares it as `latest`
267
+ deepStrictEqual(ranges.get("semver"), [
268
+ {
269
+ key: "app@1.0.0",
270
+ range: "7.8.1",
271
+ resolvedVersion: "7.8.1",
272
+ requesterName: "app",
273
+ },
274
+ ]);
275
+
276
+ for (const name of ["helper", "picocolors", "left-pad", "kleur", "chalk"]) {
277
+ strictEqual(ranges.has(name), false);
278
+ }
279
+ });
280
+
281
+ // A transitive dependent whose installed manifest declares a non-npm
282
+ // protocol falls back to the version it actually got, rather than carrying
283
+ // `workspace:*` into a semver comparison.
284
+ it("falls back to the resolved version when the manifest range is not comparable", () => {
285
+ const readWorkspaceManifest: ManifestReader = (name, version) =>
286
+ name === "@tamagui/metro-plugin" && version === "1.139.4"
287
+ ? {
288
+ dependencies: {
289
+ "metro-config": "workspace:*",
290
+ "metro-transform-worker": "*",
291
+ },
292
+ }
293
+ : undefined;
294
+
295
+ const ranges = rangesFor(
296
+ wildcardScenario,
297
+ wildcardPackages,
298
+ readWorkspaceManifest,
299
+ );
300
+
301
+ strictEqual(
302
+ rangeOf(ranges, "metro-config", wildcardDependent),
303
+ // the resolved version, not `*` and not `workspace:*`
304
+ "0.87.0",
305
+ );
306
+ strictEqual(
307
+ rangeOf(ranges, "metro-transform-worker", wildcardDependent),
308
+ "*",
309
+ );
310
+ });
311
+ });
@@ -0,0 +1,185 @@
1
+ import {
2
+ PackageDependencyDescriptorUtils,
3
+ isSemverComparable,
4
+ } from "pm-dedup-core";
5
+ import type { ClusterWorkspaceRef } from "pm-dedup-core";
6
+ import type { PnpmLockFile } from "../pnpmLockTypes.ts";
7
+ import {
8
+ parsePackageId,
9
+ resolveSnapshotDependency,
10
+ } from "./parsePnpmLockPackages.ts";
11
+ import type { PnpmProtocol } from "./pnpmProtocol.ts";
12
+ import type {
13
+ InstalledManifest,
14
+ ManifestReader,
15
+ } from "./readInstalledManifest.ts";
16
+
17
+ export interface DependentRange {
18
+ key: string;
19
+ range: string;
20
+ // version the requester actually got
21
+ resolvedVersion?: string;
22
+ // npm name of the requester, or undefined for an importer (workspace) one
23
+ requesterName?: string;
24
+ // the key the declaration sits under, when it names another package
25
+ // (`"@typescript/native": "npm:typescript@7.0.2"`). The range constrains
26
+ // `typescript` all the same, but the declaration is not `typescript`'s
27
+ aliasKey?: string;
28
+ // set for importer dependents only: where the range is declared on disk
29
+ workspace?: ClusterWorkspaceRef;
30
+ // the range comes from `peerDependencies`. pnpm resolves the peer like any
31
+ // other edge, so the requester constrains the version without holding a copy
32
+ // of its own, and the report has to name the declaration it comes from
33
+ peer?: true;
34
+ }
35
+
36
+ export type DependentRangesMap = Map<string, DependentRange[]>;
37
+
38
+ const snapshotDepTypes = ["dependencies", "optionalDependencies"] as const;
39
+ const manifestDepTypes = [
40
+ "dependencies",
41
+ "optionalDependencies",
42
+ "peerDependencies",
43
+ ] as const;
44
+
45
+ // a declaration semver cannot read says nothing about the version; the caller
46
+ // falls back to the version this requester actually got
47
+ const declaredRange = (
48
+ depName: string,
49
+ declared: string,
50
+ ): string | undefined => {
51
+ const parsed = PackageDependencyDescriptorUtils.parse<PnpmProtocol>(
52
+ depName,
53
+ declared,
54
+ );
55
+ return isSemverComparable(parsed) ? parsed.selector : undefined;
56
+ };
57
+
58
+ // which declaration a range was read from, so a peer requester can be told
59
+ // apart from one holding a copy of its own
60
+ interface RangeSource {
61
+ range: string;
62
+ peer?: true;
63
+ }
64
+
65
+ const manifestRange = (
66
+ manifest: InstalledManifest | undefined,
67
+ depName: string,
68
+ ): RangeSource | undefined => {
69
+ if (!manifest) return undefined;
70
+ for (const depType of manifestDepTypes) {
71
+ const declared = manifest[depType]?.[depName];
72
+ if (declared === undefined) continue;
73
+ const range = declaredRange(depName, declared);
74
+ if (range === undefined) return undefined;
75
+ return depType === "peerDependencies" ? { range, peer: true } : { range };
76
+ }
77
+ return undefined;
78
+ };
79
+
80
+ /**
81
+ * The peer range as the lockfile itself records it.
82
+ *
83
+ * pnpm resolves peers before writing the lockfile and folds each resolved peer
84
+ * into the snapshot's `dependencies` — which is why a peer edge is found here at
85
+ * all, unlike yarn's, where the fold lives only in the virtual packages the
86
+ * lockfile omits. But the snapshot carries the resolved *version*; the range
87
+ * stays behind in the `packages:` entry. Without it, seventeen plugins declaring
88
+ * `eslint: "^6.0.0 || ^7.0.0 || >=8.0.0"` and the like all read as exact pins of
89
+ * whatever got installed, and no version can satisfy them all — every merge
90
+ * ruled out by ranges that never said so.
91
+ *
92
+ * `packages:` keys carry no peer suffix, so the resolved id addresses the entry
93
+ * directly. `depName` is the key the requester declared, which is what an
94
+ * aliased peer sits under.
95
+ */
96
+ const lockfilePeerRange = (
97
+ lock: PnpmLockFile,
98
+ dependent: { name: string; version: string },
99
+ depName: string,
100
+ ): RangeSource | undefined => {
101
+ const declared =
102
+ lock.packages?.[`${dependent.name}@${dependent.version}`]
103
+ ?.peerDependencies?.[depName];
104
+ if (declared === undefined) return undefined;
105
+ const range = declaredRange(depName, declared);
106
+ return range === undefined ? undefined : { range, peer: true };
107
+ };
108
+
109
+ /**
110
+ * Collect, for each duplicated package, the real semver range every dependent
111
+ * declares. Direct (importer) dependents carry their specifier; transitive
112
+ * dependents' ranges are read from their installed manifest (the pnpm lockfile
113
+ * only stores the resolved version, not the range). When a manifest is missing
114
+ * (project not installed), the resolved version is used as an exact fallback.
115
+ */
116
+ export const collectDependentRanges = (
117
+ lock: PnpmLockFile,
118
+ duplicatePackageNames: Set<string>,
119
+ readManifest: ManifestReader,
120
+ ): DependentRangesMap => {
121
+ const rangesMap: DependentRangesMap = new Map();
122
+
123
+ const add = (name: string, dependent: DependentRange): void => {
124
+ if (!duplicatePackageNames.has(name)) return;
125
+ let dependents = rangesMap.get(name);
126
+ if (!dependents) {
127
+ dependents = [];
128
+ rangesMap.set(name, dependents);
129
+ }
130
+ dependents.push(dependent);
131
+ };
132
+
133
+ for (const [importerPath, project] of Object.entries(lock.importers ?? {})) {
134
+ (
135
+ ["dependencies", "devDependencies", "optionalDependencies"] as const
136
+ ).forEach((depType) => {
137
+ const deps = project[depType];
138
+ if (!deps) return;
139
+ for (const [depName, { specifier, version }] of Object.entries(deps)) {
140
+ const parsed = PackageDependencyDescriptorUtils.parse<PnpmProtocol>(
141
+ depName,
142
+ specifier,
143
+ );
144
+ // `workspace:` and `catalog:` name something other than the npm package
145
+ // sharing this key, so they constrain no npm version
146
+ if (!isSemverComparable(parsed)) continue;
147
+ add(parsed.npmName, {
148
+ key: `${importerPath === "." ? "package.json" : importerPath} in ${depType}`,
149
+ range: parsed.selector,
150
+ // an aliased importer entry stores `realName@version`, not a version
151
+ resolvedVersion: resolveSnapshotDependency(depName, version).version,
152
+ ...(parsed.isAlias ? { aliasKey: parsed.key } : {}),
153
+ workspace: { path: importerPath, depType },
154
+ });
155
+ }
156
+ });
157
+ }
158
+
159
+ for (const [snapshotKey, snapshot] of Object.entries(lock.snapshots ?? {})) {
160
+ const dependent = parsePackageId(snapshotKey);
161
+ snapshotDepTypes.forEach((depType) => {
162
+ const deps = snapshot[depType];
163
+ if (!deps) return;
164
+ for (const [depName, depValue] of Object.entries(deps)) {
165
+ const { name, version } = resolveSnapshotDependency(depName, depValue);
166
+ if (!duplicatePackageNames.has(name)) continue;
167
+ const source =
168
+ manifestRange(
169
+ readManifest(dependent.name, dependent.version),
170
+ depName,
171
+ ) ?? lockfilePeerRange(lock, dependent, depName);
172
+ add(name, {
173
+ key: `${dependent.name}@${dependent.version}`,
174
+ range: source?.range ?? version,
175
+ resolvedVersion: version,
176
+ requesterName: dependent.name,
177
+ ...(name === depName ? {} : { aliasKey: depName }),
178
+ ...(source?.peer ? { peer: source.peer } : {}),
179
+ });
180
+ }
181
+ });
182
+ }
183
+
184
+ return rangesMap;
185
+ };
@@ -0,0 +1,112 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
3
+ import { fileURLToPath } from "node:url";
4
+ import { readPnpmLock } from "../readPnpmLock.ts";
5
+ import { collectPnpmDependents } from "./collectPnpmDependents.ts";
6
+
7
+ const fixturesBase = (rel: string) =>
8
+ fileURLToPath(new URL(rel, import.meta.url));
9
+
10
+ const lockFor = (scenario: string) =>
11
+ readPnpmLock(fixturesBase(`../../test/fixtures/${scenario}/pnpm-lock.yaml`));
12
+
13
+ describe("collectPnpmDependents", () => {
14
+ it("collects the importer specifier (range) for a direct dependency", () => {
15
+ const dependents = collectPnpmDependents(lockFor("simple"));
16
+ deepStrictEqual(dependents.get("semver"), [
17
+ { key: "package.json in dependencies", version: "7.7.3" },
18
+ ]);
19
+ });
20
+
21
+ it("collects both importer range and transitive resolved versions", () => {
22
+ const dependents = collectPnpmDependents(
23
+ lockFor("duplicated-babel-frame"),
24
+ ["@babel/code-frame"],
25
+ );
26
+ const codeFrame = dependents.get("@babel/code-frame");
27
+ ok(codeFrame);
28
+ // direct importer dependency carries the specifier range
29
+ ok(
30
+ codeFrame.some(
31
+ (dependent) =>
32
+ dependent.key === "package.json in dependencies" &&
33
+ dependent.version === "7.26.2",
34
+ ),
35
+ );
36
+ // transitive dependents carry a resolved version
37
+ ok(codeFrame.some((dependent) => dependent.version === "7.29.7"));
38
+ ok(codeFrame.length >= 2);
39
+ });
40
+
41
+ // A `workspace:`, `catalog:`, `file:` or `link:` specifier names something
42
+ // other than the npm package sharing its key, and a dist-tag carries no
43
+ // comparable range. Recording them as ranges makes `semver.satisfies` answer
44
+ // "not satisfied" for every candidate, suppressing merges the real dependents
45
+ // allow.
46
+ it("leaves out importer specifiers that are not npm ranges", () => {
47
+ const dependents = collectPnpmDependents(lockFor("protocol-dependents"));
48
+
49
+ deepStrictEqual([...dependents.keys()].toSorted(), ["app", "semver"]);
50
+
51
+ // the importer declares `semver` as `latest`, so only the snapshot dependent
52
+ // of `app` remains
53
+ deepStrictEqual(dependents.get("semver"), [
54
+ { key: "app@1.0.0", version: "7.8.1" },
55
+ ]);
56
+
57
+ for (const packageName of [
58
+ "helper",
59
+ "picocolors",
60
+ "left-pad",
61
+ "kleur",
62
+ "chalk",
63
+ ]) {
64
+ strictEqual(dependents.has(packageName), false);
65
+ }
66
+ });
67
+
68
+ it("keeps the range an aliased importer specifier declares", () => {
69
+ const dependents = collectPnpmDependents(lockFor("mergeable-alias"), [
70
+ "printable-shell-command",
71
+ ]);
72
+
73
+ deepStrictEqual(
74
+ (dependents.get("printable-shell-command") ?? [])
75
+ .filter((dependent) => dependent.key.startsWith("package.json"))
76
+ .map((dependent) => dependent.version)
77
+ .toSorted(),
78
+ ["5.0.7", "^5.0.0"],
79
+ );
80
+ });
81
+
82
+ // `aliased-swapped-names`: the importer declares `"@typescript/native":
83
+ // "npm:typescript@7.0.2"` and `"typescript":
84
+ // "npm:@typescript/typescript6@6.0.2"`, so each key names the other's package.
85
+ // Filing either under its key would put an exact pin on the wrong package.
86
+ it("files a swapped alias under the package its specifier targets", () => {
87
+ const dependents = collectPnpmDependents(lockFor("aliased-swapped-names"));
88
+
89
+ deepStrictEqual(dependents.get("typescript"), [
90
+ { key: "package.json in devDependencies", version: "7.0.2" },
91
+ { key: "tool@1.0.0", version: "5.9.3" },
92
+ ]);
93
+
94
+ deepStrictEqual(dependents.get("@typescript/typescript6"), [
95
+ { key: "package.json in devDependencies", version: "6.0.2" },
96
+ ]);
97
+ });
98
+
99
+ it("restricts results to onlyPackageNames", () => {
100
+ const dependents = collectPnpmDependents(
101
+ lockFor("duplicated-printable-shell-command"),
102
+ ["printable-shell-command"],
103
+ );
104
+ strictEqual(
105
+ [...dependents.keys()].every(
106
+ (name) => name === "printable-shell-command",
107
+ ),
108
+ true,
109
+ );
110
+ ok(dependents.get("printable-shell-command"));
111
+ });
112
+ });
@@ -0,0 +1,79 @@
1
+ import {
2
+ PackageDependencyDescriptorUtils,
3
+ isSemverComparable,
4
+ } from "pm-dedup-core";
5
+ import type { PnpmLockFile } from "../pnpmLockTypes.ts";
6
+ import {
7
+ parsePackageId,
8
+ resolveSnapshotDependency,
9
+ } from "./parsePnpmLockPackages.ts";
10
+ import type { PnpmProtocol } from "./pnpmProtocol.ts";
11
+
12
+ export interface Dependent {
13
+ key: string;
14
+ version: string;
15
+ }
16
+
17
+ export type DependentsMap = Map<string, Dependent[]>;
18
+
19
+ const importerDepTypes = [
20
+ "dependencies",
21
+ "devDependencies",
22
+ "optionalDependencies",
23
+ ] as const;
24
+
25
+ const snapshotDepTypes = ["dependencies", "optionalDependencies"] as const;
26
+
27
+ export function collectPnpmDependents(
28
+ lock: PnpmLockFile,
29
+ onlyPackageNames?: string[],
30
+ ): DependentsMap {
31
+ const dependentsMap: DependentsMap = new Map();
32
+
33
+ const add = (name: string, dependent: Dependent): void => {
34
+ if (onlyPackageNames && !onlyPackageNames.includes(name)) {
35
+ return;
36
+ }
37
+ let dependents = dependentsMap.get(name);
38
+ if (!dependents) {
39
+ dependents = [];
40
+ dependentsMap.set(name, dependents);
41
+ }
42
+ dependents.push(dependent);
43
+ };
44
+
45
+ for (const [importerPath, project] of Object.entries(lock.importers ?? {})) {
46
+ importerDepTypes.forEach((depType) => {
47
+ const deps = project[depType];
48
+ if (!deps) return;
49
+ for (const [depName, { specifier }] of Object.entries(deps)) {
50
+ const parsedDep = PackageDependencyDescriptorUtils.parse<PnpmProtocol>(
51
+ depName,
52
+ specifier,
53
+ );
54
+ // `workspace:` and `catalog:` name something other than the npm package
55
+ // sharing this key, so they constrain no npm version
56
+ if (!isSemverComparable(parsedDep)) continue;
57
+ add(parsedDep.npmName, {
58
+ key: `${importerPath === "." ? "package.json" : importerPath} in ${depType}`,
59
+ version: parsedDep.selector,
60
+ });
61
+ }
62
+ });
63
+ }
64
+
65
+ for (const [snapshotKey, snapshot] of Object.entries(lock.snapshots ?? {})) {
66
+ const { name, version } = parsePackageId(snapshotKey);
67
+ const key = `${name}@${version}`;
68
+ snapshotDepTypes.forEach((depType) => {
69
+ const deps = snapshot[depType];
70
+ if (!deps) return;
71
+ for (const [depName, depValue] of Object.entries(deps)) {
72
+ const resolved = resolveSnapshotDependency(depName, depValue);
73
+ add(resolved.name, { key, version: resolved.version });
74
+ }
75
+ });
76
+ }
77
+
78
+ return dependentsMap;
79
+ }