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
package/src/dedupe.ts ADDED
@@ -0,0 +1,124 @@
1
+ import {
2
+ countDuplicatedPackages,
3
+ createPackageFilter,
4
+ diffVersionsSnapshots,
5
+ renderDedupeSummary,
6
+ } from "pm-dedup-core";
7
+ import type { PackageFilterOptions } from "pm-dedup-core";
8
+ import { applyClusterFixes } from "./applyClusterFixes.ts";
9
+ import { lockPathOf, resolvePnpmProjectDir } from "./helpers/projectDir.ts";
10
+ import { runPnpm } from "./helpers/runPnpm.ts";
11
+ import { readVersionsSnapshot } from "./helpers/versionsSnapshot.ts";
12
+
13
+ // `--dry-run` and `--check` print the same plan and write nothing; only the exit
14
+ // code differs, so `--check` can gate CI while `--dry-run` stays informational.
15
+ export type DedupeMode = "apply" | "check" | "dry-run";
16
+
17
+ export interface DedupeOptions {
18
+ mode?: DedupeMode;
19
+ // false makes the cluster pass write plain overrides instead of convergence
20
+ // ones, forcing every requester onto the version rather than only the ones
21
+ // whose range accepts it
22
+ convergenceOverrides?: boolean;
23
+ // restricts which packages may be touched, for deduplicating a large lockfile
24
+ // a family at a time
25
+ filter?: PackageFilterOptions;
26
+ }
27
+
28
+ const residualsMessage = "`pnpm dedupe` would also change the lockfile.";
29
+
30
+ // `pnpm dedupe` merges everything it can and knows nothing of the filter, so
31
+ // running it would undo the point of a filtered run.
32
+ const filteredResidualsMessage =
33
+ "`pnpm dedupe` was not run: it ignores the package filter. Run `pnpm dedupe` yourself, or this command without a filter, to finish the rest.";
34
+
35
+ /**
36
+ * `pnpm dedupe` only merges what it can resolve to a single version on its own:
37
+ * it never widens a workspace range, and it never repoints an edge that resolved
38
+ * past a version the family already carries. Cluster fixes cover exactly that
39
+ * gap, so they run first and `pnpm dedupe` finishes the residuals.
40
+ */
41
+ export function dedupe({
42
+ mode = "apply",
43
+ convergenceOverrides = true,
44
+ filter,
45
+ }: DedupeOptions = {}): void {
46
+ const dryRun = mode !== "apply";
47
+ const projectDir = resolvePnpmProjectDir();
48
+ if (projectDir === null) return;
49
+ const lockPath = lockPathOf(projectDir);
50
+ const filtered = !createPackageFilter(filter).selectsEverything;
51
+
52
+ // Read before anything runs: both the cluster pass and `pnpm dedupe` rewrite
53
+ // the lockfile, and the summary compares the whole run against this.
54
+ const before = readVersionsSnapshot(lockPath);
55
+
56
+ // What the run merged, reported once the lockfile has settled — `pnpm dedupe`
57
+ // included, since it is part of what this command does.
58
+ const summarize = (): void => {
59
+ const after = readVersionsSnapshot(lockPath);
60
+ renderDedupeSummary({
61
+ deduped: diffVersionsSnapshots(before, after),
62
+ remainingDuplicates: countDuplicatedPackages(after),
63
+ whyCommand: "pnpm-why-duplicate",
64
+ });
65
+ };
66
+
67
+ // Read-only, so it can run before the cluster pass. It has to, for a dry run:
68
+ // the plan report names the residuals, and only this knows about them. A
69
+ // filtered run does not run `pnpm dedupe` at all, so what it would change is
70
+ // not this command's business.
71
+ const probe =
72
+ dryRun && !filtered
73
+ ? runPnpm(["dedupe", "--check"], { cwd: projectDir })
74
+ : null;
75
+
76
+ const residuals = ((): string | undefined => {
77
+ if (filtered) return filteredResidualsMessage;
78
+ if (probe !== null && probe.status !== 0) return residualsMessage;
79
+ return undefined;
80
+ })();
81
+
82
+ const outcome = applyClusterFixes({
83
+ projectDir,
84
+ dryRun,
85
+ convergenceOverrides,
86
+ filter,
87
+ packageManagerResiduals: residuals,
88
+ });
89
+
90
+ if (outcome.status === "kept-overrides") {
91
+ console.log(
92
+ `${outcome.stickyOverrides.length} override(s) left in pnpm-workspace.yaml — see the comment above them`,
93
+ );
94
+ }
95
+
96
+ if (mode === "check") {
97
+ process.exitCode =
98
+ outcome.plannedChangeCount > 0 || (probe !== null && probe.status !== 0)
99
+ ? 1
100
+ : 0;
101
+ return;
102
+ }
103
+ if (mode === "dry-run") return;
104
+
105
+ if (filtered) {
106
+ // Applying an override needs a real resolution, and `pnpm dedupe` is the
107
+ // only one that performs it — so a filtered run that changed something has
108
+ // already merged whatever else pnpm could reach on its own.
109
+ if (outcome.status === "applied" || outcome.status === "kept-overrides") {
110
+ console.log(
111
+ "`pnpm dedupe` ran to apply the fixes above and merged what it could on its own: the filter bounds the edits made here, not pnpm's resolution.",
112
+ );
113
+ }
114
+ console.log(filteredResidualsMessage);
115
+ summarize();
116
+ return;
117
+ }
118
+
119
+ const result = runPnpm(["dedupe"], { cwd: projectDir });
120
+ if (result.status !== null) {
121
+ process.exitCode = result.status;
122
+ }
123
+ summarize();
124
+ }
@@ -0,0 +1,218 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { readFileSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { buildIdentifiedFixesMap } from "pm-dedup-core";
8
+ import {
9
+ buildPnpmPackagesMap,
10
+ collectPnpmDependents,
11
+ filterDuplicatesPnpmPackagesMap,
12
+ parsePnpmLockPackages,
13
+ readPnpmLock,
14
+ } from "./index.ts";
15
+
16
+ // These tests shell out to the real `pnpm` and require either network access or
17
+ // a warm pnpm store; they are skipped when pnpm is not on PATH. `--lockfile-only`
18
+ // keeps pnpm from writing node_modules, and `--frozen-lockfile` / `--check` never
19
+ // rewrite the lockfile, so pnpm runs against the committed fixtures in place
20
+ // without modifying them (asserted below). `pnpm dedupe --check` only flags the
21
+ // safely-mergeable subset of duplicates, so the invariant is: everything pnpm
22
+ // would dedupe is also reported by our listing.
23
+
24
+ const pnpmAvailable =
25
+ spawnSync("pnpm", ["--version"], { encoding: "utf8" }).status === 0;
26
+
27
+ const fixturePath = (scenario: string): string =>
28
+ fileURLToPath(new URL(`../test/fixtures/${scenario}`, import.meta.url));
29
+
30
+ interface PnpmRun {
31
+ status: number | null;
32
+ output: string;
33
+ }
34
+
35
+ const runPnpm = (cwd: string, args: string[]): PnpmRun => {
36
+ const result = spawnSync(
37
+ "pnpm",
38
+ [...args, "--lockfile-only", "--prefer-offline", "--ignore-scripts"],
39
+ { cwd, encoding: "utf8", timeout: 120_000 },
40
+ );
41
+ if (result.error) {
42
+ throw result.error;
43
+ }
44
+ return {
45
+ status: result.status,
46
+ output: `${result.stdout}\n${result.stderr}`,
47
+ };
48
+ };
49
+
50
+ // `pnpm dedupe --check` prints a tree of changes; each merged dependency is on a
51
+ // branch line `├── name fromVersion → toVersion`. We collect those names.
52
+ const parseDedupedPackages = (output: string): string[] => {
53
+ const names = new Set<string>();
54
+ for (const line of output.split("\n")) {
55
+ if (!line.includes("→")) continue;
56
+ const name = line
57
+ .replace(/^[\s│├└─]+/u, "")
58
+ .trim()
59
+ .split(/\s+/u)[0];
60
+ if (name) {
61
+ names.add(name);
62
+ }
63
+ }
64
+ return [...names].toSorted();
65
+ };
66
+
67
+ const duplicateNames = (scenario: string): string[] => {
68
+ const lock = readPnpmLock(join(fixturePath(scenario), "pnpm-lock.yaml"));
69
+ const duplicates = filterDuplicatesPnpmPackagesMap(
70
+ buildPnpmPackagesMap(parsePnpmLockPackages(lock)),
71
+ );
72
+ return Object.keys(duplicates).toSorted();
73
+ };
74
+
75
+ const fixTargets = (scenario: string, packageName: string): string[] => {
76
+ const lock = readPnpmLock(join(fixturePath(scenario), "pnpm-lock.yaml"));
77
+ const duplicates = filterDuplicatesPnpmPackagesMap(
78
+ buildPnpmPackagesMap(parsePnpmLockPackages(lock)),
79
+ );
80
+ const fixes = buildIdentifiedFixesMap(
81
+ duplicates,
82
+ collectPnpmDependents(lock, Object.keys(duplicates)),
83
+ );
84
+ return (fixes.get(packageName) ?? []).map((fix) => fix.to);
85
+ };
86
+
87
+ const lockContent = (dir: string): string =>
88
+ readFileSync(join(dir, "pnpm-lock.yaml"), "utf8");
89
+
90
+ // Defensive: `--lockfile-only` should never create node_modules, but if a future
91
+ // pnpm version does, do not leave it in the committed fixture directory.
92
+ const assertPristine = (dir: string, lockBefore: string): void => {
93
+ strictEqual(
94
+ lockContent(dir),
95
+ lockBefore,
96
+ "pnpm must not change the lockfile",
97
+ );
98
+ rmSync(join(dir, "node_modules"), { recursive: true, force: true });
99
+ };
100
+
101
+ const suite = pnpmAvailable ? describe : describe.skip;
102
+
103
+ suite("pnpm dedupe --check vs listDuplicates", () => {
104
+ // `duplicated-typescript-eslint-dedupe-peers` is the same dependency with
105
+ // `dedupePeers: true`, which flattens the peer suffixes in the lockfile: pnpm
106
+ // must still merge the same packages, and we must still list them.
107
+ const mergeable: { scenario: string; expectedFlagged: string[] }[] = [
108
+ {
109
+ scenario: "duplicated-typescript-eslint",
110
+ expectedFlagged: [
111
+ "@typescript-eslint/tsconfig-utils",
112
+ "@typescript-eslint/types",
113
+ ],
114
+ },
115
+ {
116
+ scenario: "duplicated-typescript-eslint-dedupe-peers",
117
+ expectedFlagged: [
118
+ "@typescript-eslint/tsconfig-utils",
119
+ "@typescript-eslint/types",
120
+ ],
121
+ },
122
+ ];
123
+
124
+ for (const { scenario, expectedFlagged } of mergeable) {
125
+ it(`flags the mergeable subset of ${scenario}, all of which listDuplicates reports`, () => {
126
+ const dir = fixturePath(scenario);
127
+ const lockBefore = lockContent(dir);
128
+
129
+ const install = runPnpm(dir, ["install", "--frozen-lockfile"]);
130
+ strictEqual(install.status, 0, install.output);
131
+ assertPristine(dir, lockBefore);
132
+
133
+ const check = runPnpm(dir, ["dedupe", "--check"]);
134
+ ok(
135
+ check.status !== 0,
136
+ `dedupe --check should flag issues\n${check.output}`,
137
+ );
138
+ assertPristine(dir, lockBefore);
139
+
140
+ deepStrictEqual(parseDedupedPackages(check.output), expectedFlagged);
141
+
142
+ const duplicates = duplicateNames(scenario);
143
+ for (const name of expectedFlagged) {
144
+ ok(
145
+ duplicates.includes(name),
146
+ `listDuplicates should report ${name} that pnpm dedupe flags`,
147
+ );
148
+ }
149
+ }, 180_000);
150
+ }
151
+
152
+ // Duplicates pnpm does not merge: `dedupe --check` exits 0 and flags nothing,
153
+ // yet listDuplicates still reports the duplicate. The `mergeable-alias*`
154
+ // fixtures go one step further: every declared range accepts the aliased
155
+ // 5.0.7 pin, so we do identify a merge target — one pnpm will never apply,
156
+ // because merging means downgrading the range from 5.3.1.
157
+ // `wildcard-not-reused` is the widest case: a `*` range resolved to 0.87.0
158
+ // instead of the installed 0.84.5, duplicating the metro family (see
159
+ // wildcardNotReused.test.ts). pnpm will not undo it either.
160
+ const unmergeableByPnpm: {
161
+ scenario: string;
162
+ expectedDuplicate: string;
163
+ expectedFixTargets?: string[];
164
+ }[] = [
165
+ {
166
+ scenario: "duplicated-babel-frame",
167
+ expectedDuplicate: "@babel/code-frame",
168
+ },
169
+ {
170
+ scenario: "duplicated-printable-shell-command",
171
+ expectedDuplicate: "printable-shell-command",
172
+ },
173
+ {
174
+ scenario: "mergeable-alias",
175
+ expectedDuplicate: "printable-shell-command",
176
+ expectedFixTargets: ["printable-shell-command@5.0.7"],
177
+ },
178
+ {
179
+ scenario: "mergeable-alias-dedupe-peers",
180
+ expectedDuplicate: "printable-shell-command",
181
+ expectedFixTargets: ["printable-shell-command@5.0.7"],
182
+ },
183
+ {
184
+ scenario: "wildcard-not-reused",
185
+ expectedDuplicate: "metro-config",
186
+ },
187
+ ];
188
+
189
+ for (const {
190
+ scenario,
191
+ expectedDuplicate,
192
+ expectedFixTargets,
193
+ } of unmergeableByPnpm) {
194
+ it(`flags nothing for ${scenario} but still lists ${expectedDuplicate}`, () => {
195
+ const dir = fixturePath(scenario);
196
+ const lockBefore = lockContent(dir);
197
+
198
+ const install = runPnpm(dir, ["install", "--frozen-lockfile"]);
199
+ strictEqual(install.status, 0, install.output);
200
+ assertPristine(dir, lockBefore);
201
+
202
+ const check = runPnpm(dir, ["dedupe", "--check"]);
203
+ strictEqual(
204
+ check.status,
205
+ 0,
206
+ `dedupe --check should find nothing to merge\n${check.output}`,
207
+ );
208
+ assertPristine(dir, lockBefore);
209
+
210
+ deepStrictEqual(parseDedupedPackages(check.output), []);
211
+ ok(duplicateNames(scenario).includes(expectedDuplicate));
212
+ deepStrictEqual(
213
+ fixTargets(scenario, expectedDuplicate),
214
+ expectedFixTargets ?? [],
215
+ );
216
+ }, 180_000);
217
+ }
218
+ });
@@ -0,0 +1,168 @@
1
+ import { afterEach, describe, it } from "bun:test";
2
+ import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { buildPnpmPackagesMap } from "./helpers/buildPnpmPackagesMap.ts";
8
+ import { readDuplicateSnapshot } from "./helpers/duplicateSnapshot.ts";
9
+ import { parsePnpmLockPackages } from "./helpers/parsePnpmLockPackages.ts";
10
+ import { createTempProjects } from "./helpers/tempProjects.ts";
11
+ import { readPnpmLock } from "./readPnpmLock.ts";
12
+
13
+ // The shipped tool against a real pnpm: a copy of the
14
+ // `exact-pin-forces-downgrade` fixture, a real `pnpm install`, then the built
15
+ // `pnpm-dedupe` bin as a user would run it. Every other test stubs the
16
+ // resolution step, so this is the only one that can tell whether pnpm holds the
17
+ // deduplicated result on its own once the transient override is removed again —
18
+ // the step the tool takes before reporting "applied" — and pnpm is asked for its
19
+ // own account of the tree at the end rather than only our reading of it.
20
+ //
21
+ // Requires network access or a warm pnpm store; skipped when pnpm is not on PATH.
22
+
23
+ const pnpmAvailable =
24
+ spawnSync("pnpm", ["--version"], { encoding: "utf8" }).status === 0;
25
+
26
+ const fixtureDir = fileURLToPath(
27
+ new URL("../test/fixtures/exact-pin-forces-downgrade", import.meta.url),
28
+ );
29
+ // the built bin, the one the `bin` field points at: from `src` it is one level
30
+ // up, and the compiled copy of this file sits beside it
31
+ const dedupeBin = ((): string => {
32
+ const built = ["../dist/bin/pnpm-dedupe.js", "./bin/pnpm-dedupe.js"]
33
+ .map((binPath) => fileURLToPath(new URL(binPath, import.meta.url)))
34
+ .find((binPath) => existsSync(binPath));
35
+ if (!built) {
36
+ throw new Error("pnpm-dedupe is not built: run `bun run build` first");
37
+ }
38
+ return built;
39
+ })();
40
+
41
+ // expo-camera's peers (expo, react-native) would drag a few hundred packages in
42
+ // and duplicates of their own, none of which this fixture is about.
43
+ const workspaceYamlContent = "autoInstallPeers: false\n";
44
+
45
+ interface Run {
46
+ status: number | null;
47
+ output: string;
48
+ }
49
+
50
+ const projects = createTempProjects("pnpm-dedup-e2e-");
51
+
52
+ afterEach(projects.cleanup);
53
+
54
+ const run = (cwd: string, command: string, args: string[]): Run => {
55
+ const result = spawnSync(command, args, {
56
+ cwd,
57
+ encoding: "utf8",
58
+ timeout: 120_000,
59
+ });
60
+ if (result.error) {
61
+ throw result.error;
62
+ }
63
+ return {
64
+ status: result.status,
65
+ output: `${result.stdout}\n${result.stderr}`,
66
+ };
67
+ };
68
+
69
+ const install = (cwd: string, ...args: string[]): Run =>
70
+ run(cwd, "pnpm", [
71
+ "install",
72
+ "--prefer-offline",
73
+ "--ignore-scripts",
74
+ ...args,
75
+ ]);
76
+
77
+ // node, as the bin's own shebang asks for
78
+ const dedupe = (cwd: string, ...args: string[]): Run =>
79
+ run(cwd, "node", [dedupeBin, ...args]);
80
+
81
+ const lockPath = (dir: string): string => join(dir, "pnpm-lock.yaml");
82
+
83
+ const resolutionsOf = (dir: string, packageName: string): string[] => {
84
+ const lock = readPnpmLock(lockPath(dir));
85
+ const packagesMap = buildPnpmPackagesMap(parsePnpmLockPackages(lock));
86
+ return (packagesMap[packageName] ?? []).map(({ resolution }) => resolution);
87
+ };
88
+
89
+ const suite = pnpmAvailable ? describe : describe.skip;
90
+
91
+ suite("pnpm-dedupe end to end", () => {
92
+ it("converges the cluster and leaves pnpm holding it without an override", () => {
93
+ const dir = projects.create();
94
+ // only the manifest: the lockfile has to be the real resolution pnpm writes
95
+ // here, not the trimmed one the unit-test fixture commits
96
+ cpSync(join(fixtureDir, "package.json"), join(dir, "package.json"));
97
+ writeFileSync(join(dir, "pnpm-workspace.yaml"), workspaceYamlContent);
98
+
99
+ const installed = install(dir);
100
+ strictEqual(installed.status, 0, installed.output);
101
+
102
+ // `@yudiel/react-qr-scanner` pins barcode-detector at exactly 3.0.3 while
103
+ // expo-camera's `^3.0.0` resolved to 3.2.2, and the pair drags zxing-wasm
104
+ // along: four resolutions for two packages.
105
+ const before = readDuplicateSnapshot(lockPath(dir));
106
+ strictEqual(before.size, 4, [...before].join(", "));
107
+ ok(before.has("barcode-detector@3.0.3"));
108
+ ok(before.has("barcode-detector@3.2.2"));
109
+
110
+ const applied = dedupe(dir);
111
+ strictEqual(applied.status, 0, applied.output);
112
+
113
+ // the tool's own verdict: the convergence override went in, came back out,
114
+ // and the result survived the re-resolution without it
115
+ ok(applied.output.includes('"barcode-detector@": "3.0.3"'), applied.output);
116
+ ok(
117
+ applied.output.includes(
118
+ "Removing the overrides and re-resolving to check the result holds",
119
+ ),
120
+ applied.output,
121
+ );
122
+ // what it deduped, named: two packages, one copy of each merged away
123
+ ok(
124
+ applied.output.includes("Deduped 2 packages, 2 copies merged:"),
125
+ applied.output,
126
+ );
127
+ ok(
128
+ applied.output.includes(
129
+ "barcode-detector: 2 versions (3.0.3, 3.2.2) -> 1 version (3.0.3)",
130
+ ),
131
+ applied.output,
132
+ );
133
+ ok(applied.output.includes("No duplicate left."), applied.output);
134
+
135
+ // what matters is the lockfile pnpm was left with
136
+ strictEqual(readDuplicateSnapshot(lockPath(dir)).size, 0);
137
+ deepStrictEqual(resolutionsOf(dir, "barcode-detector"), [
138
+ "barcode-detector@3.0.3",
139
+ ]);
140
+ // barcode-detector 3.2.2 pinned zxing-wasm at exactly 3.1.3; 3.0.3 asks for
141
+ // `^2.1.2`, so converging onto it has to bring the 2.x line back — a 3.1.3
142
+ // left standing would be a range the installed tree contradicts.
143
+ const zxing = resolutionsOf(dir, "zxing-wasm");
144
+ strictEqual(zxing.length, 1, zxing.join(", "));
145
+ ok(zxing[0]?.startsWith("zxing-wasm@2."), zxing.join(", "));
146
+
147
+ // no standing override is left behind, in a file otherwise untouched
148
+ strictEqual(
149
+ readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8"),
150
+ workspaceYamlContent,
151
+ );
152
+
153
+ // and pnpm itself agrees, from the manifests alone
154
+ const frozen = install(dir, "--frozen-lockfile");
155
+ strictEqual(frozen.status, 0, frozen.output);
156
+
157
+ // pnpm's own account of the installed tree, not ours: one copy, reached
158
+ // from both dependents
159
+ const list = run(dir, "pnpm", ["list", "--depth", "Infinity"]);
160
+ strictEqual(list.status, 0, list.output);
161
+ ok(list.output.includes("barcode-detector@3.0.3 [deduped]"), list.output);
162
+ strictEqual(list.output.includes("barcode-detector@3.2.2"), false);
163
+
164
+ const check = dedupe(dir, "--check");
165
+ strictEqual(check.status, 0, check.output);
166
+ ok(check.output.includes("Nothing to dedupe."), check.output);
167
+ }, 180_000);
168
+ });
@@ -0,0 +1,175 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
3
+ import { fileURLToPath } from "node:url";
4
+ import { buildIdentifiedFixesMap } from "pm-dedup-core";
5
+ import type { PackagesMap } from "./helpers/buildPnpmPackagesMap.ts";
6
+ import { parsePackageId } from "./helpers/parsePnpmLockPackages.ts";
7
+ import {
8
+ buildPnpmPackagesMap,
9
+ collectPnpmDependents,
10
+ filterDuplicatesPnpmPackagesMap,
11
+ parsePnpmLockPackages,
12
+ readPnpmLock,
13
+ } from "./index.ts";
14
+
15
+ // Both fixtures declare the same single dependency; only the `dedupePeers`
16
+ // setting differs. With it enabled pnpm writes peer suffixes as plain
17
+ // `name@version` instead of repeating the peer's own resolution, so snapshot
18
+ // ids lose their nesting:
19
+ // default: pkg@1(peer@2(eslint@10)(typescript@6))(eslint@10)(typescript@6)
20
+ // dedupePeers: pkg@1(peer@2)(eslint@10)(typescript@6)
21
+ const defaultScenario = "duplicated-typescript-eslint";
22
+ const dedupePeersScenario = "duplicated-typescript-eslint-dedupe-peers";
23
+
24
+ // `mergeable-alias` pins the same package twice, once through an npm alias
25
+ // (`npm:printable-shell-command@5.0.7`) and once through a range that also
26
+ // accepts that pin. It is the same pair: identical manifests, `dedupePeers`
27
+ // only set in `mergeable-alias-dedupe-peers`.
28
+ const mergeableDefaultScenario = "mergeable-alias";
29
+ const mergeableDedupePeersScenario = "mergeable-alias-dedupe-peers";
30
+
31
+ const lockFor = (scenario: string) =>
32
+ readPnpmLock(
33
+ fileURLToPath(
34
+ new URL(`../test/fixtures/${scenario}/pnpm-lock.yaml`, import.meta.url),
35
+ ),
36
+ );
37
+
38
+ const maxParenDepth = (id: string): number => {
39
+ let depth = 0;
40
+ let max = 0;
41
+ for (const char of id) {
42
+ if (char === "(") {
43
+ depth += 1;
44
+ max = Math.max(max, depth);
45
+ } else if (char === ")") {
46
+ depth -= 1;
47
+ }
48
+ }
49
+ return max;
50
+ };
51
+
52
+ const snapshotIds = (scenario: string): string[] =>
53
+ Object.keys(lockFor(scenario).snapshots ?? {});
54
+
55
+ const duplicatesFor = (scenario: string): PackagesMap =>
56
+ filterDuplicatesPnpmPackagesMap(
57
+ buildPnpmPackagesMap(parsePnpmLockPackages(lockFor(scenario))),
58
+ );
59
+
60
+ const fixesFor = (
61
+ scenario: string,
62
+ ): ReturnType<typeof buildIdentifiedFixesMap> => {
63
+ const lock = lockFor(scenario);
64
+ const duplicates = duplicatesFor(scenario);
65
+ return buildIdentifiedFixesMap(
66
+ duplicates,
67
+ collectPnpmDependents(lock, Object.keys(duplicates)),
68
+ );
69
+ };
70
+
71
+ describe("dedupePeers lockfiles", () => {
72
+ it("uses a fixture generated with the setting enabled", () => {
73
+ strictEqual(lockFor(dedupePeersScenario).settings?.dedupePeers, true);
74
+ strictEqual(lockFor(defaultScenario).settings?.dedupePeers, undefined);
75
+ });
76
+
77
+ it("flattens the peer suffixes the default settings nest", () => {
78
+ ok(
79
+ snapshotIds(defaultScenario).some((id) => maxParenDepth(id) > 1),
80
+ "the default-settings fixture should contain nested peer suffixes",
81
+ );
82
+ deepStrictEqual(
83
+ snapshotIds(dedupePeersScenario).filter((id) => maxParenDepth(id) > 1),
84
+ [],
85
+ );
86
+ });
87
+
88
+ it("parses name and version out of a flattened peer suffix", () => {
89
+ const suffixed = snapshotIds(dedupePeersScenario).filter((id) =>
90
+ id.includes("("),
91
+ );
92
+ ok(suffixed.length > 0);
93
+ for (const id of suffixed) {
94
+ const { name, version } = parsePackageId(id);
95
+ ok(!name.includes("("), `name should not keep the suffix: ${id}`);
96
+ ok(/^\d/u.test(version), `version should be resolved: ${id}`);
97
+ }
98
+ });
99
+
100
+ it("groups every peer context of a resolution into installations", () => {
101
+ const { packages, installationsByResolution } = parsePnpmLockPackages(
102
+ lockFor(dedupePeersScenario),
103
+ );
104
+ const utils = installationsByResolution.get(
105
+ "@typescript-eslint/utils@8.67.0",
106
+ );
107
+ ok(utils);
108
+ ok(utils.every((id) => id.startsWith("@typescript-eslint/utils@8.67.0")));
109
+ strictEqual(
110
+ [...installationsByResolution.keys()].every((resolution) =>
111
+ packages.has(resolution),
112
+ ),
113
+ true,
114
+ );
115
+ });
116
+
117
+ it("reports the same duplicated packages as the default-settings fixture", () => {
118
+ deepStrictEqual(
119
+ Object.keys(duplicatesFor(dedupePeersScenario)).toSorted(),
120
+ Object.keys(duplicatesFor(defaultScenario)).toSorted(),
121
+ );
122
+ });
123
+
124
+ it("resolves dependents for every duplicate, so fixes can be identified", () => {
125
+ const lock = lockFor(dedupePeersScenario);
126
+ const duplicates = duplicatesFor(dedupePeersScenario);
127
+ const packageNames = Object.keys(duplicates);
128
+ const dependents = collectPnpmDependents(lock, packageNames);
129
+
130
+ for (const name of packageNames) {
131
+ ok(dependents.get(name)?.length, `expected dependents for ${name}`);
132
+ }
133
+
134
+ // identifyResolutionFixes throws on a missing dependent, which is what a
135
+ // peer-suffix parsing regression would cause here.
136
+ const fixes = buildIdentifiedFixesMap(duplicates, dependents);
137
+ deepStrictEqual([...fixes.keys()].toSorted(), packageNames.toSorted());
138
+ });
139
+ });
140
+
141
+ describe("dedupePeers with an identified fix", () => {
142
+ it("flattens the peer suffixes of the pinned pair too", () => {
143
+ ok(
144
+ snapshotIds(mergeableDefaultScenario).some((id) => maxParenDepth(id) > 1),
145
+ );
146
+ deepStrictEqual(
147
+ snapshotIds(mergeableDedupePeersScenario).filter(
148
+ (id) => maxParenDepth(id) > 1,
149
+ ),
150
+ [],
151
+ );
152
+ });
153
+
154
+ it("identifies the version every declared range accepts", () => {
155
+ deepStrictEqual(
156
+ fixesFor(mergeableDedupePeersScenario).get("printable-shell-command"),
157
+ [
158
+ {
159
+ mergeableResolutions: [
160
+ "printable-shell-command@5.0.7",
161
+ "printable-shell-command@5.3.1",
162
+ ],
163
+ to: "printable-shell-command@5.0.7",
164
+ },
165
+ ],
166
+ );
167
+ });
168
+
169
+ it("identifies the same fixes as the default-settings fixture", () => {
170
+ deepStrictEqual(
171
+ [...fixesFor(mergeableDedupePeersScenario)],
172
+ [...fixesFor(mergeableDefaultScenario)],
173
+ );
174
+ });
175
+ });