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,466 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import {
4
+ applyWorkspaceRangeEdit,
5
+ captureFiles,
6
+ createPackageFilter,
7
+ describeSkippedClusterFix,
8
+ diffDuplicates,
9
+ partitionUnconditionalOverrides,
10
+ planClusterApply,
11
+ renderApplyPlan,
12
+ restoreFiles,
13
+ selectClusterFixes,
14
+ shouldColorize,
15
+ } from "pm-dedup-core";
16
+ import type {
17
+ ApplyPlanFileChange,
18
+ ClusterFix,
19
+ DuplicateSnapshot,
20
+ FileSnapshot,
21
+ PackageFilterOptions,
22
+ PlannedManifestEdit,
23
+ PlannedOverride,
24
+ SelectedClusterFixes,
25
+ } from "pm-dedup-core";
26
+ import { buildPnpmPackagesMap } from "./helpers/buildPnpmPackagesMap.ts";
27
+ import { readDuplicateSnapshot } from "./helpers/duplicateSnapshot.ts";
28
+ import { parsePnpmLockPackages } from "./helpers/parsePnpmLockPackages.ts";
29
+ import {
30
+ convergenceOverridesMinVersion,
31
+ readPnpmVersion,
32
+ supportsConvergenceOverrides,
33
+ } from "./helpers/pnpmVersion.ts";
34
+ import { addOverrides, overrideKey } from "./helpers/pnpmWorkspaceYaml.ts";
35
+ import { lockPathOf } from "./helpers/projectDir.ts";
36
+ import { createManifestReader } from "./helpers/readInstalledManifest.ts";
37
+ import { runPnpm } from "./helpers/runPnpm.ts";
38
+ import { identifyClusterFixes } from "./identifyClusterFixes.ts";
39
+ import { readPnpmLock } from "./readPnpmLock.ts";
40
+
41
+ const issuesUrl = "https://github.com/christophehurpeau/pm-tools/issues";
42
+
43
+ export type ClusterApplyStatus =
44
+ | "applied"
45
+ | "dry-run"
46
+ | "kept-overrides"
47
+ | "not-supported"
48
+ | "nothing-to-do"
49
+ | "reverted";
50
+
51
+ export interface ClusterApplyOutcome {
52
+ status: ClusterApplyStatus;
53
+ before: DuplicateSnapshot;
54
+ after: DuplicateSnapshot;
55
+ // overrides the duplicates came back without: reported, never left behind
56
+ stickyOverrides: PlannedOverride[];
57
+ // how many edits the plan holds, so `--check` gates without re-planning
58
+ plannedChangeCount: number;
59
+ }
60
+
61
+ export interface ApplyClusterFixesOptions {
62
+ projectDir: string;
63
+ dryRun?: boolean;
64
+ log?: (message?: string) => void;
65
+ // seams for tests: everything else runs against the real files
66
+ resolve?: () => number | null;
67
+ readFixes?: (projectDir: string) => ClusterFix[];
68
+ readDuplicates?: (lockPath: string) => DuplicateSnapshot;
69
+ pnpmVersion?: () => string | null;
70
+ // false writes plain overrides instead, which pnpm applies to every requester
71
+ // whatever range it declares
72
+ convergenceOverrides?: boolean;
73
+ // restricts which packages may be touched, for deduplicating a large lockfile
74
+ // a family at a time
75
+ filter?: PackageFilterOptions;
76
+ // what `pnpm dedupe` itself would still change, for the dry-run report. Only
77
+ // the caller runs that probe, so only it can say.
78
+ packageManagerResiduals?: string;
79
+ // decided by the caller, because a caller that passes `log` is not writing to
80
+ // `process.stdout` and cannot be read off it
81
+ color?: boolean;
82
+ }
83
+
84
+ const defaultReadFixes = (projectDir: string): ClusterFix[] => {
85
+ const lock = readPnpmLock(lockPathOf(projectDir));
86
+ return identifyClusterFixes(
87
+ lock,
88
+ buildPnpmPackagesMap(parsePnpmLockPackages(lock)),
89
+ createManifestReader(projectDir),
90
+ );
91
+ };
92
+
93
+ const manifestPathOf = (projectDir: string, importerPath: string): string =>
94
+ join(projectDir, importerPath === "." ? "" : importerPath, "package.json");
95
+
96
+ const describeOverride = (
97
+ override: PlannedOverride,
98
+ convergence: boolean,
99
+ ): string =>
100
+ `"${overrideKey(override.packageName, convergence)}": "${override.version}"`;
101
+
102
+ /**
103
+ * Why the overrides below them had to stay. Used verbatim as the comment left in
104
+ * `pnpm-workspace.yaml` and as the console explanation, so the file and the run
105
+ * say the same thing. The packages are the entries that follow, so they are not
106
+ * repeated here.
107
+ */
108
+ const stickyOverrideReason: string[] = [
109
+ "Added by pnpm-dedup.",
110
+ "Removing these makes `pnpm dedupe` resolve them back to a duplicate, so they",
111
+ "are what holds the deduplicated result. Keeping them is a workaround: no fix",
112
+ "was found that survives on its own. Please report this cluster so it can be",
113
+ `handled without a standing override: ${issuesUrl}`,
114
+ ];
115
+
116
+ interface ApplyState {
117
+ duplicates: DuplicateSnapshot;
118
+ // one key per open range still resolving away from the version the workspace
119
+ // anchors its family at
120
+ reuses: Set<string>;
121
+ }
122
+
123
+ const reuseKeys = (fixes: ClusterFix[]): Set<string> =>
124
+ new Set(
125
+ fixes.flatMap((fix) =>
126
+ fix.reuseFixes.map(
127
+ (reuse) => `${reuse.requesterName}>${reuse.packageName}@${reuse.to}`,
128
+ ),
129
+ ),
130
+ );
131
+
132
+ export const applyClusterFixes = ({
133
+ projectDir,
134
+ dryRun = false,
135
+ log = console.log,
136
+ // `pnpm dedupe`, not `pnpm install`: an override is applied by a read-package
137
+ // hook that only runs during a real resolution, and an incremental install
138
+ // reports "Already up to date" without ever re-reading a manifest. `dedupe`
139
+ // re-resolves, and is also what runs right after this, so it is the only
140
+ // honest thing to verify against.
141
+ resolve = () => runPnpm(["dedupe"], { cwd: projectDir }).status,
142
+ readFixes = defaultReadFixes,
143
+ readDuplicates = readDuplicateSnapshot,
144
+ pnpmVersion = () => readPnpmVersion(),
145
+ convergenceOverrides = true,
146
+ filter,
147
+ packageManagerResiduals,
148
+ color = shouldColorize(),
149
+ }: ApplyClusterFixesOptions): ClusterApplyOutcome => {
150
+ const packageFilter = createPackageFilter(filter);
151
+ const readSelectedFixes = (dir: string): SelectedClusterFixes =>
152
+ selectClusterFixes(readFixes(dir), packageFilter);
153
+ const lockPath = lockPathOf(projectDir);
154
+ const workspaceYamlPath = join(projectDir, "pnpm-workspace.yaml");
155
+
156
+ const before = readDuplicates(lockPath);
157
+ const unchanged = (
158
+ status: ClusterApplyStatus,
159
+ plannedChangeCount = 0,
160
+ ): ClusterApplyOutcome => ({
161
+ status,
162
+ before,
163
+ after: before,
164
+ stickyOverrides: [],
165
+ plannedChangeCount,
166
+ });
167
+
168
+ const { selected: fixes, skipped: filteredOut } =
169
+ readSelectedFixes(projectDir);
170
+ const plan = planClusterApply(fixes);
171
+
172
+ const skipped = [
173
+ ...filteredOut.map(describeSkippedClusterFix),
174
+ ...plan.unresolvableChanges.map(
175
+ (unresolvable) => `${unresolvable}: no workspace file recorded for it`,
176
+ ),
177
+ ...plan.conflicts.map(
178
+ (conflict) =>
179
+ `${conflict.packageName}: keeping ${conflict.kept}, ignoring ${conflict.dropped} asked by another cluster`,
180
+ ),
181
+ ];
182
+
183
+ // a plain override applies to every requester of the package, so one a
184
+ // third-party range rejects would force that dependent too
185
+ const plannedOverrides = convergenceOverrides
186
+ ? plan.overrides
187
+ : (() => {
188
+ const { safe, rejected } = partitionUnconditionalOverrides(
189
+ fixes,
190
+ plan.overrides,
191
+ );
192
+ for (const { override, rejectedBy } of rejected) {
193
+ for (const constraint of rejectedBy) {
194
+ skipped.push(
195
+ `override ${describeOverride(override, false)}: ${constraint.requesterName ?? "workspace"} requires "${constraint.range}", and a plain override would force it too`,
196
+ );
197
+ }
198
+ }
199
+ return safe;
200
+ })();
201
+
202
+ const changeCount = plan.manifestEdits.length + plannedOverrides.length;
203
+
204
+ const relativeManifestPath = (importerPath: string): string =>
205
+ relative(projectDir, manifestPathOf(projectDir, importerPath));
206
+
207
+ const planFileChanges = (): ApplyPlanFileChange[] => [
208
+ ...[
209
+ ...new Set(
210
+ plan.manifestEdits.map((edit) =>
211
+ relativeManifestPath(edit.importerPath),
212
+ ),
213
+ ),
214
+ ].map((path) => ({
215
+ path,
216
+ changes: plan.manifestEdits
217
+ .filter((edit) => relativeManifestPath(edit.importerPath) === path)
218
+ .map(
219
+ (edit) =>
220
+ `"${edit.packageName}": "${edit.range}" -> "${edit.to}" (${edit.depType})`,
221
+ ),
222
+ })),
223
+ {
224
+ path: relative(projectDir, workspaceYamlPath),
225
+ transient: "removed once the result is verified",
226
+ changes: plannedOverrides.map(
227
+ (override) =>
228
+ `${describeOverride(override, convergenceOverrides)} (${override.reason})`,
229
+ ),
230
+ },
231
+ ];
232
+
233
+ // A dry run prints the plan through the shared renderer and stops. Anything
234
+ // that rules the plan out has to be decided before that, or the report would
235
+ // promise edits pnpm cannot make.
236
+ const reportPlan = (applicable: boolean): ClusterApplyOutcome => {
237
+ renderApplyPlan({
238
+ fileChanges: applicable ? planFileChanges() : [],
239
+ skipped,
240
+ packageManagerResiduals,
241
+ dedupeCommand: "pnpm-dedupe",
242
+ log,
243
+ color,
244
+ });
245
+ return unchanged("dry-run", applicable ? changeCount : 0);
246
+ };
247
+
248
+ if (!dryRun) {
249
+ for (const entry of skipped) {
250
+ log(` Skipped ${entry}`);
251
+ }
252
+ }
253
+
254
+ if (changeCount === 0) {
255
+ return dryRun ? reportPlan(false) : unchanged("nothing-to-do");
256
+ }
257
+
258
+ if (convergenceOverrides) {
259
+ const version = pnpmVersion();
260
+ if (version === null || !supportsConvergenceOverrides(version)) {
261
+ log(
262
+ `Cluster fixes need pnpm >= ${convergenceOverridesMinVersion} (convergence overrides); found ${version ?? "no pnpm"}. Skipping them.`,
263
+ );
264
+ // nothing can be applied, so a `--check` run has nothing to fail on
265
+ return dryRun ? reportPlan(false) : unchanged("not-supported");
266
+ }
267
+ }
268
+
269
+ if (dryRun) {
270
+ return reportPlan(true);
271
+ }
272
+
273
+ const touchedPaths = [
274
+ ...new Set(
275
+ plan.manifestEdits.map((edit) =>
276
+ manifestPathOf(projectDir, edit.importerPath),
277
+ ),
278
+ ),
279
+ workspaceYamlPath,
280
+ lockPath,
281
+ ];
282
+
283
+ const originalSnapshot = captureFiles(touchedPaths);
284
+
285
+ const revertTo = (snapshot: FileSnapshot[]): void => {
286
+ restoreFiles(snapshot);
287
+ resolve();
288
+ };
289
+
290
+ const editManifests = (edits: PlannedManifestEdit[]): boolean =>
291
+ edits.every((edit) => {
292
+ const path = manifestPathOf(projectDir, edit.importerPath);
293
+ const updated = applyWorkspaceRangeEdit(readFileSync(path, "utf8"), edit);
294
+ if (updated === undefined) {
295
+ log(
296
+ ` ${path} no longer declares "${edit.packageName}": "${edit.range}" — the lockfile is out of date`,
297
+ );
298
+ return false;
299
+ }
300
+ log(
301
+ ` ${path}: "${edit.packageName}" ${edit.range} -> ${edit.to} in ${edit.depType}`,
302
+ );
303
+ writeFileSync(path, updated);
304
+ return true;
305
+ });
306
+
307
+ // A reuse fix removes no duplicate — both copies keep their dependents — so
308
+ // the duplicate set cannot tell whether it took. The detector can: the fix is
309
+ // gone from its output once the edge points at the anchored version.
310
+ const readState = (): ApplyState => ({
311
+ duplicates: readDuplicates(lockPath),
312
+ reuses: reuseKeys(readSelectedFixes(projectDir).selected),
313
+ });
314
+
315
+ // A step keeps its edits as long as it broke nothing: a widened range often
316
+ // deduplicates nothing on its own and only makes the overrides applicable.
317
+ const resolveAndCheck = (label: string): ApplyState | null => {
318
+ if (resolve() !== 0) {
319
+ log(` \`pnpm dedupe\` failed after ${label} — reverting`);
320
+ return null;
321
+ }
322
+ const state = readState();
323
+ const { added } = diffDuplicates(before, state.duplicates);
324
+ if (added.length > 0) {
325
+ log(` ${label} introduced ${added.length} new duplicate(s) — reverting`);
326
+ return null;
327
+ }
328
+ return state;
329
+ };
330
+
331
+ if (plan.manifestEdits.length > 0) {
332
+ log("Editing workspace ranges:");
333
+ if (!editManifests(plan.manifestEdits)) {
334
+ revertTo(originalSnapshot);
335
+ return unchanged("reverted", changeCount);
336
+ }
337
+ if (resolveAndCheck("the workspace range edits") === null) {
338
+ revertTo(originalSnapshot);
339
+ return unchanged("reverted", changeCount);
340
+ }
341
+ }
342
+
343
+ const withoutOverridesSnapshot = captureFiles(touchedPaths);
344
+ const workspaceYamlBefore = withoutOverridesSnapshot.find(
345
+ (snapshot) => snapshot.path === workspaceYamlPath,
346
+ )!;
347
+
348
+ const remaining = readState();
349
+ // An override is still worth writing while its package is duplicated, or
350
+ // while the edge it repoints is still resolving elsewhere.
351
+ const outstanding = plannedOverrides.filter(
352
+ (override) =>
353
+ [...remaining.duplicates].some((resolution) =>
354
+ resolution.startsWith(`${override.packageName}@`),
355
+ ) ||
356
+ [...remaining.reuses].some((key) =>
357
+ key.endsWith(`>${override.packageName}@${override.version}`),
358
+ ),
359
+ );
360
+
361
+ if (outstanding.length === 0) {
362
+ return {
363
+ status: "applied",
364
+ before,
365
+ after: remaining.duplicates,
366
+ stickyOverrides: [],
367
+ plannedChangeCount: changeCount,
368
+ };
369
+ }
370
+
371
+ log(
372
+ `Adding ${convergenceOverrides ? "convergence" : "plain"} overrides to pnpm-workspace.yaml:`,
373
+ );
374
+ for (const override of outstanding) {
375
+ log(
376
+ ` ${describeOverride(override, convergenceOverrides)} (${override.reason})`,
377
+ );
378
+ }
379
+ writeFileSync(
380
+ workspaceYamlPath,
381
+ addOverrides(
382
+ workspaceYamlBefore.content,
383
+ new Map(
384
+ outstanding.map((override) => [override.packageName, override.version]),
385
+ ),
386
+ { convergence: convergenceOverrides },
387
+ ),
388
+ );
389
+
390
+ const withOverrides = resolveAndCheck("the overrides");
391
+ if (withOverrides === null) {
392
+ revertTo(withoutOverridesSnapshot);
393
+ return unchanged("reverted", changeCount);
394
+ }
395
+
396
+ // Overrides are scaffolding first: if pnpm holds the result without them they
397
+ // are removed, and if it does not they go back with the reason recorded.
398
+ log("Removing the overrides and re-resolving to check the result holds:");
399
+ restoreFiles([workspaceYamlBefore]);
400
+
401
+ if (resolve() !== 0) {
402
+ log(" `pnpm dedupe` failed without the overrides — reverting");
403
+ revertTo(withoutOverridesSnapshot);
404
+ return unchanged("reverted", changeCount);
405
+ }
406
+
407
+ const after = readState();
408
+ const returnedDuplicates = diffDuplicates(
409
+ withOverrides.duplicates,
410
+ after.duplicates,
411
+ ).added;
412
+ const returnedReuses = [...after.reuses].filter(
413
+ (key) => !withOverrides.reuses.has(key),
414
+ );
415
+
416
+ if (returnedDuplicates.length > 0 || returnedReuses.length > 0) {
417
+ writeFileSync(
418
+ workspaceYamlPath,
419
+ addOverrides(
420
+ workspaceYamlBefore.content,
421
+ new Map(
422
+ outstanding.map((override) => [
423
+ override.packageName,
424
+ override.version,
425
+ ]),
426
+ ),
427
+ {
428
+ convergence: convergenceOverrides,
429
+ comment: stickyOverrideReason.join("\n"),
430
+ },
431
+ ),
432
+ );
433
+
434
+ if (resolve() !== 0) {
435
+ log(" `pnpm dedupe` failed with the overrides back — reverting");
436
+ revertTo(withoutOverridesSnapshot);
437
+ return unchanged("reverted", changeCount);
438
+ }
439
+
440
+ log(
441
+ ` Kept in ${workspaceYamlPath}, with the same explanation as a comment:`,
442
+ );
443
+ for (const override of outstanding) {
444
+ log(` ${describeOverride(override, convergenceOverrides)}`);
445
+ }
446
+ for (const line of stickyOverrideReason) {
447
+ log(` ${line}`);
448
+ }
449
+
450
+ return {
451
+ status: "kept-overrides",
452
+ before,
453
+ after: readDuplicates(lockPath),
454
+ stickyOverrides: outstanding,
455
+ plannedChangeCount: changeCount,
456
+ };
457
+ }
458
+
459
+ return {
460
+ status: "applied",
461
+ before,
462
+ after: after.duplicates,
463
+ stickyOverrides: [],
464
+ plannedChangeCount: changeCount,
465
+ };
466
+ };
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ packageFilterParseArgsOptions,
5
+ packageFilterUsage,
6
+ parseBinArgs,
7
+ toPackageFilterOptions,
8
+ } from "pm-dedup-core";
9
+ import { dedupe } from "../dedupe.ts";
10
+ import type { DedupeMode } from "../dedupe.ts";
11
+
12
+ const usage = `Usage: pnpm-dedupe [options]
13
+
14
+ --check print the plan, change nothing, exit 1 if anything would change
15
+ -n, --dry-run print the plan, change nothing
16
+ --no-convergence-overrides write plain overrides instead of convergence ones
17
+
18
+ ${packageFilterUsage}`;
19
+
20
+ const parsed = parseBinArgs(usage, {
21
+ args: process.argv.slice(2),
22
+ options: {
23
+ check: { type: "boolean" },
24
+ "dry-run": { type: "boolean", short: "n" },
25
+ "no-convergence-overrides": { type: "boolean" },
26
+ ...packageFilterParseArgsOptions,
27
+ },
28
+ });
29
+
30
+ if (parsed !== null) {
31
+ const { values } = parsed;
32
+
33
+ const mode = ((): DedupeMode => {
34
+ if (values.check) return "check";
35
+ if (values["dry-run"]) return "dry-run";
36
+ return "apply";
37
+ })();
38
+
39
+ dedupe({
40
+ mode,
41
+ convergenceOverrides: !values["no-convergence-overrides"],
42
+ filter: toPackageFilterOptions(values),
43
+ });
44
+ }
@@ -0,0 +1,79 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { cpSync, mkdirSync, realpathSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createTempProjects } from "../helpers/tempProjects.ts";
7
+
8
+ // The bins run from `src` under the test runtime; the `bin` field points at the
9
+ // built copy in `dist/bin`, which the end-to-end suite covers.
10
+ const binPath = (name: string): string =>
11
+ fileURLToPath(new URL(`./${name}.ts`, import.meta.url));
12
+
13
+ const fixtureDir = (name: string): string =>
14
+ fileURLToPath(new URL(`../../test/fixtures/${name}`, import.meta.url));
15
+
16
+ const projects = createTempProjects("pnpm-dedup-bin-");
17
+
18
+ afterEach(projects.cleanup);
19
+
20
+ interface Run {
21
+ status: number | null;
22
+ stdout: string;
23
+ stderr: string;
24
+ }
25
+
26
+ const runBin = (name: string, cwd: string, args: string[]): Run => {
27
+ const result = spawnSync(process.execPath, [binPath(name), ...args], {
28
+ cwd,
29
+ encoding: "utf8",
30
+ timeout: 60_000,
31
+ });
32
+ if (result.error) throw result.error;
33
+ return {
34
+ status: result.status,
35
+ stdout: result.stdout,
36
+ stderr: result.stderr,
37
+ };
38
+ };
39
+
40
+ const project = (fixture: string): string => {
41
+ const dir = projects.create();
42
+ cpSync(fixtureDir(fixture), dir, { recursive: true });
43
+ return dir;
44
+ };
45
+
46
+ // A spawned bin reads its own cwd back resolved — the temp root is reached
47
+ // through a symlink on macOS — so the paths it prints are the physical ones.
48
+ const lockNotice = (dir: string): string =>
49
+ `using ${join(realpathSync(dir), "pnpm-lock.yaml")}`;
50
+
51
+ describe("pnpm-why-duplicate", () => {
52
+ describe("run from a subdirectory", () => {
53
+ it("reports on the project the lockfile is in", () => {
54
+ const dir = project("duplicated-printable-shell-command");
55
+ const nested = join(dir, "packages", "app", "src");
56
+ mkdirSync(nested, { recursive: true });
57
+
58
+ const fromRoot = runBin("pnpm-why-duplicate", dir, ["--details"]);
59
+ const fromNested = runBin("pnpm-why-duplicate", nested, ["--details"]);
60
+
61
+ expect(fromNested.status).toBe(0);
62
+ expect(fromNested.stdout).toBe(fromRoot.stdout);
63
+ expect(fromNested.stdout).toContain("printable-shell-command");
64
+ expect(fromNested.stderr.trim()).toBe(lockNotice(dir));
65
+ expect(fromRoot.stderr).toBe("");
66
+ });
67
+
68
+ it("names the lockfile it could not find and exits 1", () => {
69
+ const dir = projects.create();
70
+
71
+ const result = runBin("pnpm-why-duplicate", dir, []);
72
+
73
+ expect(result.status).toBe(1);
74
+ expect(result.stderr.trim()).toBe(
75
+ `No pnpm-lock.yaml found in ${realpathSync(dir)} or any parent directory`,
76
+ );
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ parseBinArgs,
5
+ toWhyDuplicateRequest,
6
+ whyDuplicateParseArgsOptions,
7
+ whyDuplicateUsage,
8
+ } from "pm-dedup-core";
9
+ import { listDuplicates, whyDuplicate } from "../index.ts";
10
+
11
+ const usage = `Usage: pnpm-why-duplicate [package] [options]
12
+
13
+ [package] a name or a glob; the short form of --packages
14
+ ${whyDuplicateUsage}`;
15
+
16
+ const parsed = parseBinArgs(usage, {
17
+ args: process.argv.slice(2),
18
+ options: whyDuplicateParseArgsOptions,
19
+ allowPositionals: true,
20
+ });
21
+
22
+ if (parsed !== null) {
23
+ const { values, positionals } = parsed;
24
+ const { filter, explains, all, details } = toWhyDuplicateRequest(
25
+ values,
26
+ positionals,
27
+ );
28
+
29
+ if (explains) {
30
+ whyDuplicate({ filter, all, details });
31
+ } else {
32
+ listDuplicates({ filter, details });
33
+ }
34
+ }