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,430 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { ClusterFix } from "pm-dedup-core";
6
+ import { buildPnpmPackagesMap } from "./helpers/buildPnpmPackagesMap.ts";
7
+ import { parsePnpmLockPackages } from "./helpers/parsePnpmLockPackages.ts";
8
+ import type { ManifestReader } from "./helpers/readInstalledManifest.ts";
9
+ import { createManifestReader } from "./helpers/readInstalledManifest.ts";
10
+ import { identifyClusterFixes } from "./identifyClusterFixes.ts";
11
+ import { readPnpmLock } from "./readPnpmLock.ts";
12
+
13
+ const fixturesBase = (rel: string) =>
14
+ fileURLToPath(new URL(rel, import.meta.url));
15
+
16
+ // The fixtures are committed without node_modules, so the declared ranges of
17
+ // the requesters that matter are stubbed; the others fall back to their
18
+ // resolved version, as an uninstalled project would.
19
+ const manifests: Record<string, Record<string, string>> = {
20
+ "@tamagui/metro-plugin@1.139.4": {
21
+ "metro-config": "*",
22
+ "metro-transform-worker": "*",
23
+ },
24
+ "@react-native/community-cli-plugin@0.87.0": { metro: "^0.87.0" },
25
+ "@react-native/metro-config@0.87.0": { "metro-config": "^0.87.0" },
26
+ "react-native-web@0.21.2": { "@react-native/normalize-colors": "^0.74.1" },
27
+ "react-native-reanimated@4.5.3": { "react-native": "0.83 - 0.86" },
28
+ "react-native-worklets@0.11.4": {
29
+ "react-native": "0.83 - 0.86",
30
+ "@react-native/metro-config": "*",
31
+ },
32
+ "@pob/eslint-config@65.5.0": {
33
+ "@typescript-eslint/eslint-plugin": "^8.59.1",
34
+ "@typescript-eslint/parser": "^8.59.1",
35
+ "typescript-eslint": "^8.59.1",
36
+ },
37
+ "@pob/eslint-plugin@65.5.0": { "@typescript-eslint/utils": "8.59.1" },
38
+ "eslint-plugin-import-x@4.16.2": {
39
+ "@typescript-eslint/types": "^8.56.0",
40
+ "@typescript-eslint/utils": "^8.56.0",
41
+ },
42
+ };
43
+
44
+ // The @tamagui/* and react-native-* packages peer-depend on react-native with
45
+ // an open range (`*`, or `0.83 - 0.86` for reanimated/worklets, both above):
46
+ // without them react-native's ranges read as exact resolved versions and the
47
+ // "nobody asks for this version" guard is never exercised.
48
+ const readStubbedManifest: ManifestReader = (name, version) => {
49
+ const dependencies = manifests[`${name}@${version}`];
50
+ if (dependencies) return { dependencies };
51
+ if (name.startsWith("@tamagui/") || name.startsWith("react-native-")) {
52
+ return { peerDependencies: { "react-native": "*" } };
53
+ }
54
+ return undefined;
55
+ };
56
+
57
+ const clusterFixesFor = (
58
+ scenario: string,
59
+ readManifest: ManifestReader = readStubbedManifest,
60
+ ): ClusterFix[] => {
61
+ const lock = readPnpmLock(
62
+ fixturesBase(`../test/fixtures/${scenario}/pnpm-lock.yaml`),
63
+ );
64
+ return identifyClusterFixes(
65
+ lock,
66
+ buildPnpmPackagesMap(parsePnpmLockPackages(lock)),
67
+ readManifest,
68
+ );
69
+ };
70
+
71
+ // Only the families: a duplicate with no lockstep sibling is reported as a
72
+ // cluster of one, which the tests below single out on its own.
73
+ const lockstepFixesFor = (
74
+ scenario: string,
75
+ readManifest: ManifestReader = readStubbedManifest,
76
+ ): ClusterFix[] =>
77
+ clusterFixesFor(scenario, readManifest).filter(
78
+ (fix) => fix.members.length > 1,
79
+ );
80
+
81
+ const metroCluster = (): ClusterFix => {
82
+ const fix = clusterFixesFor("wildcard-not-reused").find((candidate) =>
83
+ candidate.members.includes("metro"),
84
+ );
85
+ ok(fix, "expected a cluster containing metro");
86
+ return fix;
87
+ };
88
+
89
+ describe("identifyClusterFixes", () => {
90
+ // Same expectations as bun-dedup's identifyClusterFixes on its own
91
+ // `duplicated-typescript-eslint` fixture: the pnpm adapter must reach the
92
+ // same conclusion from a lockfile shaped very differently.
93
+ it("identifies the @typescript-eslint cluster dedupable to 8.59.1", () => {
94
+ const fixes = lockstepFixesFor("duplicated-typescript-eslint");
95
+ strictEqual(fixes.length, 1);
96
+ const fix = fixes[0]!;
97
+
98
+ strictEqual(fix.applicable, true);
99
+ strictEqual(fix.target, "8.59.1");
100
+ strictEqual(fix.direction, "down");
101
+ strictEqual(fix.needsRoundTrip, true);
102
+ ok(fix.members.includes("typescript-eslint"));
103
+ deepStrictEqual(fix.duplicatedMembers, [
104
+ "@typescript-eslint/project-service",
105
+ "@typescript-eslint/scope-manager",
106
+ "@typescript-eslint/tsconfig-utils",
107
+ "@typescript-eslint/types",
108
+ "@typescript-eslint/typescript-estree",
109
+ "@typescript-eslint/utils",
110
+ "@typescript-eslint/visitor-keys",
111
+ ]);
112
+ deepStrictEqual(fix.reResolutionSet, [
113
+ "@typescript-eslint/eslint-plugin",
114
+ "@typescript-eslint/parser",
115
+ "typescript-eslint",
116
+ ]);
117
+ });
118
+
119
+ it("finds no target when the requesters' manifests are unavailable", () => {
120
+ // Without node_modules every transitive range degrades to the resolved
121
+ // version, so `@pob/eslint-config`'s `^8.59.1` reads as `8.61.0` and
122
+ // `@pob/eslint-plugin`'s pin keeps utils at 8.59.1: no version is left that
123
+ // any duplicate can collapse onto.
124
+ const fixes = lockstepFixesFor(
125
+ "duplicated-typescript-eslint",
126
+ () => undefined,
127
+ );
128
+ strictEqual(fixes.length, 1);
129
+ const fix = fixes[0]!;
130
+ strictEqual(fix.applicable, false);
131
+ strictEqual(fix.target, null);
132
+ deepStrictEqual(fix.reuseFixes, []);
133
+ });
134
+
135
+ // `hoisted-node-linker` is the shape of a real repo that reported no fix at
136
+ // all: with `nodeLinker: hoisted` there is no `node_modules/.pnpm/<pkg>` to
137
+ // read, so every declared range degraded to the resolved version. It is the
138
+ // only fixture whose manifests are committed, so the real reader runs here.
139
+ const hoistedFixtureDir = fixturesBase(
140
+ "../test/fixtures/hoisted-node-linker",
141
+ );
142
+
143
+ const hoistedClusterFix = (readManifest: ManifestReader): ClusterFix => {
144
+ const lock = readPnpmLock(join(hoistedFixtureDir, "pnpm-lock.yaml"));
145
+ const fixes = identifyClusterFixes(
146
+ lock,
147
+ buildPnpmPackagesMap(parsePnpmLockPackages(lock)),
148
+ readManifest,
149
+ );
150
+ strictEqual(fixes.length, 1);
151
+ return fixes[0]!;
152
+ };
153
+
154
+ it("reads the ranges out of a hoisted node_modules", () => {
155
+ const fix = hoistedClusterFix(createManifestReader(hoistedFixtureDir));
156
+
157
+ // the pinned 0.84.5 wins: `mini-plugin` asks for `*`, so nothing external
158
+ // wants 0.87.0 and the pin needs no editing
159
+ strictEqual(fix.applicable, true);
160
+ strictEqual(fix.target, "0.84.5");
161
+ strictEqual(fix.direction, "down");
162
+ deepStrictEqual(fix.convergentMembers, ["mini-metro", "mini-metro-config"]);
163
+ deepStrictEqual(fix.driverMembers, ["mini-metro"]);
164
+ deepStrictEqual(fix.workspaceChanges, []);
165
+ strictEqual(fix.needsRoundTrip, false);
166
+ deepStrictEqual(
167
+ fix.reuseFixes.map(
168
+ (reuse) =>
169
+ `${reuse.requesterName}>${reuse.packageName} ${reuse.from} -> ${reuse.to}`,
170
+ ),
171
+ [
172
+ // read from a nested manifest three levels deep, via `.modules.yaml`
173
+ "mini-deep>mini-metro-config 0.87.0 -> 0.84.5",
174
+ "mini-plugin>mini-metro-config 0.87.0 -> 0.84.5",
175
+ ],
176
+ );
177
+ });
178
+
179
+ it("inverts the advice when the layout hides the manifests", () => {
180
+ // What the same lockfile reports when the manifests cannot be read:
181
+ // `mini-plugin`'s `*` reads as an exact `0.87.0`, so keeping the pin
182
+ // collapses nothing and the fix becomes "upgrade the pin" — the opposite of
183
+ // the answer above. Hence the warning when nothing is readable.
184
+ const fix = hoistedClusterFix(() => undefined);
185
+
186
+ strictEqual(fix.target, "0.87.0");
187
+ strictEqual(fix.direction, "up");
188
+ deepStrictEqual(
189
+ fix.workspaceChanges.map(
190
+ (change) => `${change.packageName} ${change.range} -> ${change.to}`,
191
+ ),
192
+ ["mini-metro 0.84.5 -> 0.87.0"],
193
+ );
194
+ deepStrictEqual(fix.reuseFixes, []);
195
+ });
196
+
197
+ it("returns no cluster fix when there is no lockstep family", () => {
198
+ deepStrictEqual(lockstepFixesFor("duplicated-printable-shell-command"), []);
199
+ });
200
+
201
+ // `exact-pin-forces-downgrade` commits its virtual store, so the real reader
202
+ // runs: `@yudiel/react-qr-scanner` pins barcode-detector at exactly 3.0.3
203
+ // while `expo-camera` declares `^3.0.0` and resolved 3.2.2.
204
+ const downgradeFixtureDir = fixturesBase(
205
+ "../test/fixtures/exact-pin-forces-downgrade",
206
+ );
207
+
208
+ it("converges a lone duplicate on the version every range accepts", () => {
209
+ const fixes = clusterFixesFor(
210
+ "exact-pin-forces-downgrade",
211
+ createManifestReader(downgradeFixtureDir),
212
+ );
213
+ const fix = fixes.find((candidate) =>
214
+ candidate.members.includes("barcode-detector"),
215
+ );
216
+ ok(fix, "expected a fix for barcode-detector");
217
+
218
+ deepStrictEqual(fix.members, ["barcode-detector"]);
219
+ strictEqual(fix.applicable, true);
220
+ // 3.2.2 is rejected by the exact pin, so converging means moving the caret
221
+ // dependent down — which only an override can do
222
+ strictEqual(fix.target, "3.0.3");
223
+ strictEqual(fix.direction, "down");
224
+ deepStrictEqual(fix.convergentMembers, ["barcode-detector"]);
225
+ deepStrictEqual(fix.driverMembers, ["barcode-detector"]);
226
+ deepStrictEqual(fix.workspaceChanges, []);
227
+ deepStrictEqual(
228
+ fix.externalConstraints.map((constraint) => [
229
+ constraint.requesterName,
230
+ constraint.range,
231
+ ]),
232
+ [
233
+ ["@yudiel/react-qr-scanner", "3.0.3"],
234
+ ["expo-camera", "^3.0.0"],
235
+ ],
236
+ );
237
+ });
238
+
239
+ it("finds nothing for the duplicate a convergence drags along", () => {
240
+ const fixes = clusterFixesFor(
241
+ "exact-pin-forces-downgrade",
242
+ createManifestReader(downgradeFixtureDir),
243
+ );
244
+ const fix = fixes.find((candidate) =>
245
+ candidate.members.includes("zxing-wasm"),
246
+ );
247
+ ok(fix, "expected a fix record for zxing-wasm");
248
+
249
+ // barcode-detector@3.0.3 asks `^2.1.2` and 3.2.2 asks exactly 3.1.3
250
+ strictEqual(fix.applicable, false);
251
+ strictEqual(fix.target, null);
252
+ });
253
+
254
+ it("leaves a lone duplicate alone when only a workspace pin could move", () => {
255
+ // `printable-shell-command` is pinned at 5.0.7 by the workspace and at
256
+ // 5.3.1 by `betterdisplaycli`: converging means bumping the user's own pin,
257
+ // which is an upgrade decision rather than a dedupe.
258
+ deepStrictEqual(clusterFixesFor("duplicated-printable-shell-command"), []);
259
+ });
260
+
261
+ it("skips clusters whose members are all single-version", () => {
262
+ for (const fix of lockstepFixesFor("wildcard-not-reused")) {
263
+ ok(fix.duplicatedMembers.length > 0);
264
+ }
265
+ });
266
+
267
+ it("detects the metro family the non-reused wildcard duplicated", () => {
268
+ const fix = metroCluster();
269
+
270
+ for (const member of [
271
+ "metro",
272
+ "metro-config",
273
+ "metro-transform-worker",
274
+ "ob1",
275
+ ]) {
276
+ ok(fix.duplicatedMembers.includes(member), `expected ${member}`);
277
+ }
278
+ strictEqual(fix.duplicatedMembers.length, 16);
279
+ });
280
+
281
+ it("keeps external constraints and drops the derived internal pins", () => {
282
+ const byRequester = metroCluster().externalConstraints.map(
283
+ (constraint) =>
284
+ `${constraint.requesterName ?? "workspace"} -> ${constraint.packageName} @ ${constraint.range}`,
285
+ );
286
+
287
+ for (const expected of [
288
+ "workspace -> metro @ 0.84.5",
289
+ "@tamagui/metro-plugin -> metro-config @ *",
290
+ "@tamagui/metro-plugin -> metro-transform-worker @ *",
291
+ "react-native-web -> @react-native/normalize-colors @ ^0.74.1",
292
+ ]) {
293
+ ok(byRequester.includes(expected), `expected constraint ${expected}`);
294
+ }
295
+
296
+ // metro's own `metro-config: 0.84.5` / `0.87.0` pins are internal: they
297
+ // exist only because metro resolved where it did.
298
+ const requesterNames = new Set(
299
+ metroCluster().externalConstraints.map(
300
+ (constraint) => constraint.requesterName,
301
+ ),
302
+ );
303
+ strictEqual(requesterNames.has("metro"), false);
304
+ strictEqual(requesterNames.has("metro-config"), false);
305
+ });
306
+
307
+ it("converges the family on the pinned version, without touching the pin", () => {
308
+ const fix = metroCluster();
309
+
310
+ // The root pins metro at 0.84.5 on purpose, so the pin is a constraint to
311
+ // resolve around, not something to upgrade: the family collapses onto
312
+ // 0.84.5 and `workspaceChanges` stays empty.
313
+ strictEqual(fix.applicable, true);
314
+ strictEqual(fix.target, "0.84.5");
315
+ strictEqual(fix.direction, "down");
316
+ strictEqual(fix.convergentMembers.length, 15);
317
+ deepStrictEqual(fix.workspaceChanges, []);
318
+
319
+ // metro is the only member a real range applies to (the pin): the 14
320
+ // metro-* packages are requested through `*` or through metro's own exact
321
+ // pins, so they carry no decision and follow it.
322
+ deepStrictEqual(fix.driverMembers, ["metro"]);
323
+ });
324
+
325
+ it("leaves react-native's version to the resolver", () => {
326
+ const fix = metroCluster();
327
+
328
+ // react-native@0.87.0 and @react-native/metro-config@0.87.0 are what hold
329
+ // the 0.87.0 metro subtree, and nothing pins either of them there — both
330
+ // arrive through `*` / `0.83 - 0.86` peer ranges (0.87.0 does not even
331
+ // satisfy the latter). They have to move, but which version they land on is
332
+ // the resolver's call, so no version is asserted for them.
333
+ deepStrictEqual(fix.floatingMembers, [
334
+ "@react-native/metro-config",
335
+ "react-native",
336
+ ]);
337
+ deepStrictEqual(fix.reResolutionSet, []);
338
+ strictEqual(fix.needsRoundTrip, true);
339
+ });
340
+
341
+ it("repoints the wildcards that ignored the pinned version", () => {
342
+ const fix = metroCluster();
343
+
344
+ // What the fixture is actually about: `@tamagui/metro-plugin` declares
345
+ // `metro-config: "*"` / `metro-transform-worker: "*"`, and pnpm resolved
346
+ // both to 0.87.0 although the pinned 0.84.5 was already in the tree and the
347
+ // range accepts it — so the plugin runs against a different metro than the
348
+ // app's.
349
+ strictEqual(fix.anchor, "0.84.5");
350
+ deepStrictEqual(
351
+ fix.reuseFixes.map(
352
+ (reuse) =>
353
+ `${reuse.requesterName}>${reuse.packageName} "${reuse.range}" ${reuse.from} -> ${reuse.to}`,
354
+ ),
355
+ [
356
+ '@tamagui/metro-plugin>metro-config "*" 0.87.0 -> 0.84.5',
357
+ '@tamagui/metro-plugin>metro-transform-worker "*" 0.87.0 -> 0.84.5',
358
+ ],
359
+ );
360
+ });
361
+
362
+ it("leaves alone the ranges that cannot accept the pinned version", () => {
363
+ const repointed = new Set(
364
+ metroCluster().reuseFixes.map((reuse) => reuse.packageName),
365
+ );
366
+
367
+ // `@react-native/metro-config` requires `metro-config: ^0.87.0`, and
368
+ // `react-native-web` requires `@react-native/normalize-colors: ^0.74.1`:
369
+ // neither accepts 0.84.5, so neither is repointed.
370
+ strictEqual(repointed.has("@react-native/normalize-colors"), false);
371
+ deepStrictEqual(
372
+ metroCluster()
373
+ .reuseFixes.filter(
374
+ (reuse) => reuse.requesterName === "@react-native/metro-config",
375
+ )
376
+ .map((reuse) => reuse.packageName),
377
+ [],
378
+ );
379
+ });
380
+
381
+ // `aliased-swapped-names`: the importer pins the real `typescript` at 7.0.2
382
+ // through the `@typescript/native` key while `tool` needs 5.9.3, and the
383
+ // `typescript` key holds an unrelated package. Nothing converges, and nothing
384
+ // may propose editing the declaration that names the other package.
385
+ it("leaves an exact pin declared under a swapped alias key alone", () => {
386
+ const fixes = clusterFixesFor("aliased-swapped-names", () => undefined);
387
+ deepStrictEqual(
388
+ fixes.map((fix) => fix.members),
389
+ [["typescript"]],
390
+ );
391
+ strictEqual(fixes[0]!.applicable, false);
392
+ deepStrictEqual(fixes[0]!.workspaceChanges, []);
393
+ });
394
+
395
+ // The same swapped keys, with `typescript` sitting in a lockstep family (its
396
+ // platform binaries) as it does from 7.0.0 on: the family makes the cluster
397
+ // big enough to escape the singleton guard above, and the pin is then the only
398
+ // thing standing between the dedupe and a rewrite of `"@typescript/native":
399
+ // "npm:typescript@7.0.2"` into `npm:typescript@6.0.3` — the alias repointed at
400
+ // the very package it exists to sit beside.
401
+ it("never repoints an alias to converge its family", () => {
402
+ const fix = clusterFixesFor(
403
+ "aliased-swapped-names-cluster",
404
+ (name, version) =>
405
+ name === "@typescript/typescript6" && version === "6.0.2"
406
+ ? { dependencies: { typescript: "^6" } }
407
+ : undefined,
408
+ ).find((candidate) => candidate.members.includes("typescript"));
409
+
410
+ ok(fix);
411
+ strictEqual(fix.members.length, 3);
412
+ deepStrictEqual(fix.duplicatedMembers, ["typescript"]);
413
+ strictEqual(fix.applicable, false);
414
+ strictEqual(fix.target, null);
415
+ deepStrictEqual(fix.workspaceChanges, []);
416
+ });
417
+
418
+ it("reports a cluster no member can leave as not dedupable", () => {
419
+ // metro pins hermes-parser exactly at 0.35.0 and @react-native/* at 0.36.1,
420
+ // and hermes-estree only exists twice because hermes-parser does: nothing
421
+ // can collapse.
422
+ const fix = clusterFixesFor("wildcard-not-reused").find((candidate) =>
423
+ candidate.members.includes("hermes-parser"),
424
+ );
425
+ ok(fix);
426
+ strictEqual(fix.applicable, false);
427
+ strictEqual(fix.target, null);
428
+ deepStrictEqual(fix.convergentMembers, []);
429
+ });
430
+ });
@@ -0,0 +1,114 @@
1
+ import {
2
+ buildLockstepClusters,
3
+ identifyLockstepClusterFixes,
4
+ } from "pm-dedup-core";
5
+ import type {
6
+ ClusterDependentsMap,
7
+ ClusterFix,
8
+ ClusterMembersMap,
9
+ } from "pm-dedup-core";
10
+ import type { PackagesMap } from "./helpers/buildPnpmPackagesMap.ts";
11
+ import { collectDependentRanges } from "./helpers/collectDependentRanges.ts";
12
+ import type { ManifestReader } from "./helpers/readInstalledManifest.ts";
13
+ import { toLockstepGraph } from "./helpers/toLockstepGraph.ts";
14
+ import type { PnpmLockFile } from "./pnpmLockTypes.ts";
15
+
16
+ export type {
17
+ ClusterExternalConstraint,
18
+ ClusterFix,
19
+ ClusterReuseFix,
20
+ } from "pm-dedup-core";
21
+
22
+ // A lone duplicated package is a cluster of one: nothing co-versions with it,
23
+ // but it converges through the same mechanism — the dependents that hold its
24
+ // copies apart — and it is applied the same way, by an override the resolver
25
+ // then has to confirm.
26
+ const singletonClusters = (
27
+ packagesMap: PackagesMap,
28
+ clustered: Set<string>,
29
+ ): string[][] =>
30
+ Object.entries(packagesMap)
31
+ .filter(
32
+ ([name, resolutions]) =>
33
+ !clustered.has(name) &&
34
+ resolutions.filter((resolution) => resolution.package.type === "npm")
35
+ .length > 1,
36
+ )
37
+ .map(([name]) => [name]);
38
+
39
+ const toClusterMembers = (
40
+ packagesMap: PackagesMap,
41
+ memberNames: string[],
42
+ ): ClusterMembersMap =>
43
+ Object.fromEntries(
44
+ memberNames.map((name) => {
45
+ const resolutions = packagesMap[name] ?? [];
46
+ return [
47
+ name,
48
+ {
49
+ npmVersions: resolutions
50
+ .filter((resolution) => resolution.package.type === "npm")
51
+ .map((resolution) => resolution.package.version),
52
+ resolutionCount: resolutions.length,
53
+ },
54
+ ];
55
+ }),
56
+ );
57
+
58
+ // The pnpm lockfile stores resolved versions, not requested ranges, so the
59
+ // constraints have to come from `collectDependentRanges`, which recovers the
60
+ // declared range from each requester's installed manifest.
61
+ const toClusterDependents = (
62
+ lock: PnpmLockFile,
63
+ memberNames: string[],
64
+ readManifest: ManifestReader,
65
+ ): ClusterDependentsMap => {
66
+ const ranges = collectDependentRanges(
67
+ lock,
68
+ new Set(memberNames),
69
+ readManifest,
70
+ );
71
+
72
+ return new Map(
73
+ [...ranges].map(([name, dependents]) => [
74
+ name,
75
+ dependents.map((dependent) => ({
76
+ requester: dependent.key,
77
+ requesterName: dependent.requesterName,
78
+ range: dependent.range,
79
+ isAlias: dependent.aliasKey !== undefined,
80
+ resolvedVersion: dependent.resolvedVersion,
81
+ workspace: dependent.workspace,
82
+ })),
83
+ ]),
84
+ );
85
+ };
86
+
87
+ export const identifyClusterFixes = (
88
+ lock: PnpmLockFile,
89
+ packagesMap: PackagesMap,
90
+ readManifest: ManifestReader,
91
+ ): ClusterFix[] => {
92
+ const lockstepClusters = buildLockstepClusters(
93
+ toLockstepGraph(lock, packagesMap),
94
+ );
95
+ const clusters = [
96
+ ...lockstepClusters,
97
+ ...singletonClusters(packagesMap, new Set(lockstepClusters.flat())),
98
+ ];
99
+ if (clusters.length === 0) return [];
100
+
101
+ const memberNames = [...new Set(clusters.flat())];
102
+
103
+ return identifyLockstepClusterFixes(
104
+ clusters,
105
+ toClusterMembers(packagesMap, memberNames),
106
+ toClusterDependents(lock, memberNames, readManifest),
107
+ // A lone duplicate that only converges by editing a range the workspace
108
+ // declares is an upgrade of a direct dependency, not a dedupe: that pin is
109
+ // the whole decision here, where in a family it is one constraint among
110
+ // many. Left to the user.
111
+ ).filter(
112
+ (fix) => fix.members.length > 1 || fix.workspaceChanges.length === 0,
113
+ );
114
+ };
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import fs from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { identifyResolutionFixes } from "pm-dedup-core";
5
+ import type { PackageResolution } from "./helpers/buildPnpmPackagesMap.ts";
6
+ import type { Dependent } from "./helpers/collectPnpmDependents.ts";
7
+
8
+ const loadResolutionsFixture = (fileName: string): PackageResolution[] => {
9
+ return JSON.parse(
10
+ fs.readFileSync(
11
+ fileURLToPath(
12
+ new URL(`../test/fixtures/resolutions/${fileName}`, import.meta.url),
13
+ ),
14
+ // eslint-disable-next-line unicorn/prefer-json-parse-buffer
15
+ "utf8",
16
+ ),
17
+ );
18
+ };
19
+
20
+ const loadDependentsFixture = (
21
+ fileName: string,
22
+ ): Record<string, Dependent[]> => {
23
+ return JSON.parse(
24
+ fs.readFileSync(
25
+ fileURLToPath(
26
+ new URL(`../test/fixtures/dependents/${fileName}`, import.meta.url),
27
+ ),
28
+ // eslint-disable-next-line unicorn/prefer-json-parse-buffer
29
+ "utf8",
30
+ ),
31
+ );
32
+ };
33
+
34
+ const objetToMap = <K extends string, V>(obj: Record<K, V>): Map<K, V> =>
35
+ new Map(Object.entries(obj) as [K, V][]);
36
+
37
+ describe("identifyResolutionFixes", () => {
38
+ it("should return an empty array when there are no resolutions", () => {
39
+ const resolutions: PackageResolution[] = [];
40
+
41
+ const fixes = identifyResolutionFixes(resolutions, objetToMap({}));
42
+ expect(fixes).toEqual([]);
43
+ });
44
+
45
+ it("should return an empty array when there is only one resolution", () => {
46
+ const resolutions = loadResolutionsFixture("semver-7.7.3.json");
47
+ const dependents = loadDependentsFixture("semver-7.7.3.json");
48
+ const fixes = identifyResolutionFixes(resolutions, objetToMap(dependents));
49
+ expect(fixes).toEqual([]);
50
+ });
51
+
52
+ it("should not identify fixes when dependencies are not compatible", () => {
53
+ const resolutions = loadResolutionsFixture(
54
+ "babel-code-frame-7.26.2-7.27.1.json",
55
+ );
56
+ const dependents = loadDependentsFixture(
57
+ "babel-code-frame-7.26.2-7.27.1.json",
58
+ );
59
+ const fixes = identifyResolutionFixes(resolutions, objetToMap(dependents));
60
+ expect(fixes).toEqual([]);
61
+ });
62
+
63
+ it("should identify resolution fixes when dependencies are compatible", () => {
64
+ const resolutions = loadResolutionsFixture(
65
+ "printable-shell-command-5.0.7-5.0.8.json",
66
+ );
67
+ const dependents = loadDependentsFixture(
68
+ "printable-shell-command-5.0.7-5.0.8.json",
69
+ );
70
+
71
+ const fixes = identifyResolutionFixes(resolutions, objetToMap(dependents));
72
+ expect(fixes).toEqual([
73
+ {
74
+ mergeableResolutions: [
75
+ "printable-shell-command@5.0.7",
76
+ "printable-shell-command@5.0.8",
77
+ ],
78
+ to: "printable-shell-command@5.0.8",
79
+ },
80
+ ]);
81
+ });
82
+ });