pm-dedup-core 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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/dist/buildIdentifiedFixesMap.js +6 -0
  4. package/dist/buildIdentifiedFixesMap.js.map +1 -0
  5. package/dist/buildLockstepClusters.js +119 -0
  6. package/dist/buildLockstepClusters.js.map +1 -0
  7. package/dist/buildLockstepClusters.test.js +71 -0
  8. package/dist/buildLockstepClusters.test.js.map +1 -0
  9. package/dist/clusterLabel.js +59 -0
  10. package/dist/clusterLabel.js.map +1 -0
  11. package/dist/clusterLabel.test.js +56 -0
  12. package/dist/clusterLabel.test.js.map +1 -0
  13. package/dist/compareCandidateVersions.js +18 -0
  14. package/dist/compareCandidateVersions.js.map +1 -0
  15. package/dist/compareCandidateVersions.test.js +32 -0
  16. package/dist/compareCandidateVersions.test.js.map +1 -0
  17. package/dist/createPackageFilter.js +81 -0
  18. package/dist/createPackageFilter.js.map +1 -0
  19. package/dist/createPackageFilter.test.js +115 -0
  20. package/dist/createPackageFilter.test.js.map +1 -0
  21. package/dist/definitions/buildIdentifiedFixesMap.d.ts +4 -0
  22. package/dist/definitions/buildIdentifiedFixesMap.d.ts.map +1 -0
  23. package/dist/definitions/buildLockstepClusters.d.ts +8 -0
  24. package/dist/definitions/buildLockstepClusters.d.ts.map +1 -0
  25. package/dist/definitions/buildLockstepClusters.test.d.ts +2 -0
  26. package/dist/definitions/buildLockstepClusters.test.d.ts.map +1 -0
  27. package/dist/definitions/clusterLabel.d.ts +8 -0
  28. package/dist/definitions/clusterLabel.d.ts.map +1 -0
  29. package/dist/definitions/clusterLabel.test.d.ts +2 -0
  30. package/dist/definitions/clusterLabel.test.d.ts.map +1 -0
  31. package/dist/definitions/compareCandidateVersions.d.ts +10 -0
  32. package/dist/definitions/compareCandidateVersions.d.ts.map +1 -0
  33. package/dist/definitions/compareCandidateVersions.test.d.ts +2 -0
  34. package/dist/definitions/compareCandidateVersions.test.d.ts.map +1 -0
  35. package/dist/definitions/createPackageFilter.d.ts +45 -0
  36. package/dist/definitions/createPackageFilter.d.ts.map +1 -0
  37. package/dist/definitions/createPackageFilter.test.d.ts +2 -0
  38. package/dist/definitions/createPackageFilter.test.d.ts.map +1 -0
  39. package/dist/definitions/duplicateSnapshot.d.ts +8 -0
  40. package/dist/definitions/duplicateSnapshot.d.ts.map +1 -0
  41. package/dist/definitions/fileSnapshot.d.ts +7 -0
  42. package/dist/definitions/fileSnapshot.d.ts.map +1 -0
  43. package/dist/definitions/findProjectRoot.d.ts +27 -0
  44. package/dist/definitions/findProjectRoot.d.ts.map +1 -0
  45. package/dist/definitions/findProjectRoot.test.d.ts +2 -0
  46. package/dist/definitions/findProjectRoot.test.d.ts.map +1 -0
  47. package/dist/definitions/identifyLockstepClusterFixes.d.ts +89 -0
  48. package/dist/definitions/identifyLockstepClusterFixes.d.ts.map +1 -0
  49. package/dist/definitions/identifyLockstepClusterFixes.test.d.ts +2 -0
  50. package/dist/definitions/identifyLockstepClusterFixes.test.d.ts.map +1 -0
  51. package/dist/definitions/identifyResolutionFixes.d.ts +28 -0
  52. package/dist/definitions/identifyResolutionFixes.d.ts.map +1 -0
  53. package/dist/definitions/identifyResolutionFixes.test.d.ts +2 -0
  54. package/dist/definitions/identifyResolutionFixes.test.d.ts.map +1 -0
  55. package/dist/definitions/index.d.ts +44 -0
  56. package/dist/definitions/index.d.ts.map +1 -0
  57. package/dist/definitions/packageDependenciesUtils.d.ts +47 -0
  58. package/dist/definitions/packageDependenciesUtils.d.ts.map +1 -0
  59. package/dist/definitions/packageDependenciesUtils.test.d.ts +2 -0
  60. package/dist/definitions/packageDependenciesUtils.test.d.ts.map +1 -0
  61. package/dist/definitions/packageFilterArgs.d.ts +74 -0
  62. package/dist/definitions/packageFilterArgs.d.ts.map +1 -0
  63. package/dist/definitions/packageFilterArgs.test.d.ts +2 -0
  64. package/dist/definitions/packageFilterArgs.test.d.ts.map +1 -0
  65. package/dist/definitions/packageStyles.d.ts +14 -0
  66. package/dist/definitions/packageStyles.d.ts.map +1 -0
  67. package/dist/definitions/packageStyles.test.d.ts +2 -0
  68. package/dist/definitions/packageStyles.test.d.ts.map +1 -0
  69. package/dist/definitions/parseBinArgs.d.ts +14 -0
  70. package/dist/definitions/parseBinArgs.d.ts.map +1 -0
  71. package/dist/definitions/parseBinArgs.test.d.ts +2 -0
  72. package/dist/definitions/parseBinArgs.test.d.ts.map +1 -0
  73. package/dist/definitions/planClusterApply.d.ts +39 -0
  74. package/dist/definitions/planClusterApply.d.ts.map +1 -0
  75. package/dist/definitions/planClusterApply.test.d.ts +2 -0
  76. package/dist/definitions/planClusterApply.test.d.ts.map +1 -0
  77. package/dist/definitions/renderApplyPlan.d.ts +22 -0
  78. package/dist/definitions/renderApplyPlan.d.ts.map +1 -0
  79. package/dist/definitions/renderApplyPlan.test.d.ts +2 -0
  80. package/dist/definitions/renderApplyPlan.test.d.ts.map +1 -0
  81. package/dist/definitions/renderDedupeSummary.d.ts +22 -0
  82. package/dist/definitions/renderDedupeSummary.d.ts.map +1 -0
  83. package/dist/definitions/renderDedupeSummary.test.d.ts +2 -0
  84. package/dist/definitions/renderDedupeSummary.test.d.ts.map +1 -0
  85. package/dist/definitions/renderDuplicatesReport.d.ts +39 -0
  86. package/dist/definitions/renderDuplicatesReport.d.ts.map +1 -0
  87. package/dist/definitions/renderDuplicatesReport.test.d.ts +2 -0
  88. package/dist/definitions/renderDuplicatesReport.test.d.ts.map +1 -0
  89. package/dist/definitions/reportColors.d.ts +25 -0
  90. package/dist/definitions/reportColors.d.ts.map +1 -0
  91. package/dist/definitions/reportColors.test.d.ts +2 -0
  92. package/dist/definitions/reportColors.test.d.ts.map +1 -0
  93. package/dist/definitions/reportText.d.ts +29 -0
  94. package/dist/definitions/reportText.d.ts.map +1 -0
  95. package/dist/definitions/selectExplainedPackages.d.ts +22 -0
  96. package/dist/definitions/selectExplainedPackages.d.ts.map +1 -0
  97. package/dist/definitions/selectExplainedPackages.test.d.ts +2 -0
  98. package/dist/definitions/selectExplainedPackages.test.d.ts.map +1 -0
  99. package/dist/definitions/unconditionalOverrides.d.ts +24 -0
  100. package/dist/definitions/unconditionalOverrides.d.ts.map +1 -0
  101. package/dist/definitions/unconditionalOverrides.test.d.ts +2 -0
  102. package/dist/definitions/unconditionalOverrides.test.d.ts.map +1 -0
  103. package/dist/definitions/versionsSnapshot.d.ts +40 -0
  104. package/dist/definitions/versionsSnapshot.d.ts.map +1 -0
  105. package/dist/definitions/versionsSnapshot.test.d.ts +2 -0
  106. package/dist/definitions/versionsSnapshot.test.d.ts.map +1 -0
  107. package/dist/definitions/workspaceManifest.d.ts +21 -0
  108. package/dist/definitions/workspaceManifest.d.ts.map +1 -0
  109. package/dist/definitions/workspaceManifest.test.d.ts +2 -0
  110. package/dist/definitions/workspaceManifest.test.d.ts.map +1 -0
  111. package/dist/duplicateSnapshot.js +10 -0
  112. package/dist/duplicateSnapshot.js.map +1 -0
  113. package/dist/fileSnapshot.js +21 -0
  114. package/dist/fileSnapshot.js.map +1 -0
  115. package/dist/findProjectRoot.js +48 -0
  116. package/dist/findProjectRoot.js.map +1 -0
  117. package/dist/findProjectRoot.test.js +53 -0
  118. package/dist/findProjectRoot.test.js.map +1 -0
  119. package/dist/identifyLockstepClusterFixes.js +352 -0
  120. package/dist/identifyLockstepClusterFixes.js.map +1 -0
  121. package/dist/identifyLockstepClusterFixes.test.js +372 -0
  122. package/dist/identifyLockstepClusterFixes.test.js.map +1 -0
  123. package/dist/identifyResolutionFixes.js +102 -0
  124. package/dist/identifyResolutionFixes.js.map +1 -0
  125. package/dist/identifyResolutionFixes.test.js +70 -0
  126. package/dist/identifyResolutionFixes.test.js.map +1 -0
  127. package/dist/index.js +24 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/packageDependenciesUtils.js +91 -0
  130. package/dist/packageDependenciesUtils.js.map +1 -0
  131. package/dist/packageDependenciesUtils.test.js +539 -0
  132. package/dist/packageDependenciesUtils.test.js.map +1 -0
  133. package/dist/packageFilterArgs.js +65 -0
  134. package/dist/packageFilterArgs.js.map +1 -0
  135. package/dist/packageFilterArgs.test.js +93 -0
  136. package/dist/packageFilterArgs.test.js.map +1 -0
  137. package/dist/packageStyles.js +33 -0
  138. package/dist/packageStyles.js.map +1 -0
  139. package/dist/packageStyles.test.js +58 -0
  140. package/dist/packageStyles.test.js.map +1 -0
  141. package/dist/parseBinArgs.js +28 -0
  142. package/dist/parseBinArgs.js.map +1 -0
  143. package/dist/parseBinArgs.test.js +62 -0
  144. package/dist/parseBinArgs.test.js.map +1 -0
  145. package/dist/planClusterApply.js +69 -0
  146. package/dist/planClusterApply.js.map +1 -0
  147. package/dist/planClusterApply.test.js +152 -0
  148. package/dist/planClusterApply.test.js.map +1 -0
  149. package/dist/renderApplyPlan.js +51 -0
  150. package/dist/renderApplyPlan.js.map +1 -0
  151. package/dist/renderApplyPlan.test.js +106 -0
  152. package/dist/renderApplyPlan.test.js.map +1 -0
  153. package/dist/renderDedupeSummary.js +38 -0
  154. package/dist/renderDedupeSummary.js.map +1 -0
  155. package/dist/renderDedupeSummary.test.js +91 -0
  156. package/dist/renderDedupeSummary.test.js.map +1 -0
  157. package/dist/renderDuplicatesReport.js +375 -0
  158. package/dist/renderDuplicatesReport.js.map +1 -0
  159. package/dist/renderDuplicatesReport.test.js +598 -0
  160. package/dist/renderDuplicatesReport.test.js.map +1 -0
  161. package/dist/reportColors.js +51 -0
  162. package/dist/reportColors.js.map +1 -0
  163. package/dist/reportColors.test.js +49 -0
  164. package/dist/reportColors.test.js.map +1 -0
  165. package/dist/reportText.js +29 -0
  166. package/dist/reportText.js.map +1 -0
  167. package/dist/selectExplainedPackages.js +42 -0
  168. package/dist/selectExplainedPackages.js.map +1 -0
  169. package/dist/selectExplainedPackages.test.js +55 -0
  170. package/dist/selectExplainedPackages.test.js.map +1 -0
  171. package/dist/unconditionalOverrides.js +36 -0
  172. package/dist/unconditionalOverrides.js.map +1 -0
  173. package/dist/unconditionalOverrides.test.js +71 -0
  174. package/dist/unconditionalOverrides.test.js.map +1 -0
  175. package/dist/versionsSnapshot.js +49 -0
  176. package/dist/versionsSnapshot.js.map +1 -0
  177. package/dist/versionsSnapshot.test.js +79 -0
  178. package/dist/versionsSnapshot.test.js.map +1 -0
  179. package/dist/workspaceManifest.js +63 -0
  180. package/dist/workspaceManifest.js.map +1 -0
  181. package/dist/workspaceManifest.test.js +159 -0
  182. package/dist/workspaceManifest.test.js.map +1 -0
  183. package/package.json +68 -0
  184. package/src/buildIdentifiedFixesMap.ts +22 -0
  185. package/src/buildLockstepClusters.test.ts +90 -0
  186. package/src/buildLockstepClusters.ts +155 -0
  187. package/src/clusterLabel.test.ts +79 -0
  188. package/src/clusterLabel.ts +75 -0
  189. package/src/compareCandidateVersions.test.ts +39 -0
  190. package/src/compareCandidateVersions.ts +23 -0
  191. package/src/createPackageFilter.test.ts +156 -0
  192. package/src/createPackageFilter.ts +142 -0
  193. package/src/duplicateSnapshot.ts +24 -0
  194. package/src/fileSnapshot.ts +28 -0
  195. package/src/findProjectRoot.test.ts +66 -0
  196. package/src/findProjectRoot.ts +61 -0
  197. package/src/identifyLockstepClusterFixes.test.ts +462 -0
  198. package/src/identifyLockstepClusterFixes.ts +602 -0
  199. package/src/identifyResolutionFixes.test.ts +133 -0
  200. package/src/identifyResolutionFixes.ts +183 -0
  201. package/src/index.ts +123 -0
  202. package/src/packageDependenciesUtils.test.ts +593 -0
  203. package/src/packageDependenciesUtils.ts +156 -0
  204. package/src/packageFilterArgs.test.ts +116 -0
  205. package/src/packageFilterArgs.ts +98 -0
  206. package/src/packageStyles.test.ts +77 -0
  207. package/src/packageStyles.ts +42 -0
  208. package/src/parseBinArgs.test.ts +73 -0
  209. package/src/parseBinArgs.ts +33 -0
  210. package/src/planClusterApply.test.ts +163 -0
  211. package/src/planClusterApply.ts +105 -0
  212. package/src/renderApplyPlan.test.ts +136 -0
  213. package/src/renderApplyPlan.ts +95 -0
  214. package/src/renderDedupeSummary.test.ts +121 -0
  215. package/src/renderDedupeSummary.ts +78 -0
  216. package/src/renderDuplicatesReport.test.ts +724 -0
  217. package/src/renderDuplicatesReport.ts +645 -0
  218. package/src/reportColors.test.ts +57 -0
  219. package/src/reportColors.ts +78 -0
  220. package/src/reportText.ts +58 -0
  221. package/src/selectExplainedPackages.test.ts +67 -0
  222. package/src/selectExplainedPackages.ts +79 -0
  223. package/src/unconditionalOverrides.test.ts +99 -0
  224. package/src/unconditionalOverrides.ts +60 -0
  225. package/src/versionsSnapshot.test.ts +146 -0
  226. package/src/versionsSnapshot.ts +93 -0
  227. package/src/workspaceManifest.test.ts +220 -0
  228. package/src/workspaceManifest.ts +100 -0
@@ -0,0 +1,163 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual } from "node:assert/strict";
3
+ import type { ClusterFix } from "./identifyLockstepClusterFixes.ts";
4
+ import { planClusterApply } from "./planClusterApply.ts";
5
+
6
+ const fix = (overrides: Partial<ClusterFix>): ClusterFix => ({
7
+ members: [],
8
+ duplicatedMembers: [],
9
+ memberVersions: {},
10
+ target: null,
11
+ direction: "none",
12
+ convergentMembers: [],
13
+ driverMembers: [],
14
+ excludedMembers: [],
15
+ anchor: null,
16
+ reuseFixes: [],
17
+ floatingMembers: [],
18
+ workspaceChanges: [],
19
+ reResolutionSet: [],
20
+ externalConstraints: [],
21
+ needsRoundTrip: false,
22
+ applicable: false,
23
+ ...overrides,
24
+ });
25
+
26
+ describe("planClusterApply", () => {
27
+ it("edits the importer the workspace change points at", () => {
28
+ const plan = planClusterApply([
29
+ fix({
30
+ applicable: true,
31
+ target: "0.87.0",
32
+ workspaceChanges: [
33
+ {
34
+ requester: "package.json in devDependencies",
35
+ requesterName: undefined,
36
+ packageName: "metro",
37
+ range: "0.84.5",
38
+ to: "0.87.0",
39
+ workspace: { path: ".", depType: "devDependencies" },
40
+ },
41
+ ],
42
+ }),
43
+ ]);
44
+
45
+ deepStrictEqual(plan.manifestEdits, [
46
+ {
47
+ importerPath: ".",
48
+ depType: "devDependencies",
49
+ packageName: "metro",
50
+ range: "0.84.5",
51
+ to: "0.87.0",
52
+ },
53
+ ]);
54
+ deepStrictEqual(plan.unresolvableChanges, []);
55
+ });
56
+
57
+ it("reports a workspace change it cannot locate on disk", () => {
58
+ const plan = planClusterApply([
59
+ fix({
60
+ applicable: true,
61
+ target: "0.87.0",
62
+ workspaceChanges: [
63
+ {
64
+ requester: "package.json in devDependencies",
65
+ requesterName: undefined,
66
+ packageName: "metro",
67
+ range: "0.84.5",
68
+ to: "0.87.0",
69
+ },
70
+ ],
71
+ }),
72
+ ]);
73
+
74
+ deepStrictEqual(plan.manifestEdits, []);
75
+ deepStrictEqual(plan.unresolvableChanges, [
76
+ "metro in package.json in devDependencies",
77
+ ]);
78
+ });
79
+
80
+ it("overrides the convergent members and the ones to re-resolve", () => {
81
+ const plan = planClusterApply([
82
+ fix({
83
+ applicable: true,
84
+ target: "8.59.1",
85
+ convergentMembers: ["@typescript-eslint/types"],
86
+ reResolutionSet: ["@typescript-eslint/parser"],
87
+ // spared by construction: a convergence override skips the edges whose
88
+ // declared range rejects the target
89
+ excludedMembers: [
90
+ { packageName: "@typescript-eslint/utils", blockedBy: [] },
91
+ ],
92
+ }),
93
+ ]);
94
+
95
+ deepStrictEqual(plan.overrides, [
96
+ {
97
+ packageName: "@typescript-eslint/parser",
98
+ version: "8.59.1",
99
+ reason: "converge",
100
+ },
101
+ {
102
+ packageName: "@typescript-eslint/types",
103
+ version: "8.59.1",
104
+ reason: "converge",
105
+ },
106
+ ]);
107
+ });
108
+
109
+ it("lets the anchored reuse win over a computed target", () => {
110
+ const plan = planClusterApply([
111
+ fix({
112
+ applicable: true,
113
+ target: "0.87.0",
114
+ convergentMembers: ["mini-metro-config"],
115
+ anchor: "0.84.5",
116
+ reuseFixes: [
117
+ {
118
+ requester: "mini-plugin@1.0.0",
119
+ requesterName: "mini-plugin",
120
+ packageName: "mini-metro-config",
121
+ range: "*",
122
+ from: "0.87.0",
123
+ to: "0.84.5",
124
+ },
125
+ ],
126
+ }),
127
+ ]);
128
+
129
+ deepStrictEqual(plan.overrides, [
130
+ {
131
+ packageName: "mini-metro-config",
132
+ version: "0.84.5",
133
+ reason: "reuse",
134
+ },
135
+ ]);
136
+ deepStrictEqual(plan.conflicts, [
137
+ { packageName: "mini-metro-config", kept: "0.84.5", dropped: "0.87.0" },
138
+ ]);
139
+ });
140
+
141
+ it("ignores a cluster with no applicable fix but keeps its reuse fixes", () => {
142
+ const plan = planClusterApply([
143
+ fix({
144
+ applicable: false,
145
+ convergentMembers: ["ignored"],
146
+ reuseFixes: [
147
+ {
148
+ requester: "plugin@1",
149
+ requesterName: "plugin",
150
+ packageName: "leaf",
151
+ range: "*",
152
+ from: "2.0.0",
153
+ to: "1.0.0",
154
+ },
155
+ ],
156
+ }),
157
+ ]);
158
+
159
+ deepStrictEqual(plan.overrides, [
160
+ { packageName: "leaf", version: "1.0.0", reason: "reuse" },
161
+ ]);
162
+ });
163
+ });
@@ -0,0 +1,105 @@
1
+ import type { ClusterFix } from "./identifyLockstepClusterFixes.ts";
2
+ import type { WorkspaceRangeEdit } from "./workspaceManifest.ts";
3
+
4
+ export interface PlannedManifestEdit extends WorkspaceRangeEdit {
5
+ importerPath: string;
6
+ }
7
+
8
+ export interface PlannedOverride {
9
+ packageName: string;
10
+ version: string;
11
+ // why it is proposed, for the log and for the diagnostic on a failed verify
12
+ reason: "converge" | "reuse";
13
+ }
14
+
15
+ export interface ClusterApplyPlan {
16
+ manifestEdits: PlannedManifestEdit[];
17
+ overrides: PlannedOverride[];
18
+ // a package two clusters want at different versions: the first one wins and
19
+ // the loser is reported rather than silently dropped
20
+ conflicts: { packageName: string; kept: string; dropped: string }[];
21
+ // workspace changes carrying no importer reference, which cannot be applied
22
+ unresolvableChanges: string[];
23
+ }
24
+
25
+ /**
26
+ * Turn the detector's records into the two edits every package manager
27
+ * understands:
28
+ *
29
+ * - a `workspaceChanges` entry is a range the user declares, so it is edited in
30
+ * place in the importer's `package.json`;
31
+ * - everything else is a transitive edge, which only an override can repoint.
32
+ *
33
+ * The overrides are per package name and carry no scoping: a package manager
34
+ * whose overrides apply unconditionally (bun) has to drop the ones a third-party
35
+ * range would reject, where one with conditional overrides (pnpm's convergence
36
+ * overrides) can write them all and let the resolver spare the members
37
+ * `excludedMembers` lists.
38
+ *
39
+ * `reuseFixes` come first: they converge on `anchor`, the version the user
40
+ * pinned, and a pin outranks a version the detector merely computed.
41
+ */
42
+ export const planClusterApply = (fixes: ClusterFix[]): ClusterApplyPlan => {
43
+ const manifestEdits: PlannedManifestEdit[] = [];
44
+ const unresolvableChanges: string[] = [];
45
+ const conflicts: ClusterApplyPlan["conflicts"] = [];
46
+ const overrides = new Map<string, PlannedOverride>();
47
+
48
+ const addOverride = (
49
+ packageName: string,
50
+ version: string,
51
+ reason: PlannedOverride["reason"],
52
+ ): void => {
53
+ const existing = overrides.get(packageName);
54
+ if (existing) {
55
+ if (existing.version !== version) {
56
+ conflicts.push({
57
+ packageName,
58
+ kept: existing.version,
59
+ dropped: version,
60
+ });
61
+ }
62
+ return;
63
+ }
64
+ overrides.set(packageName, { packageName, version, reason });
65
+ };
66
+
67
+ for (const fix of fixes) {
68
+ for (const reuse of fix.reuseFixes) {
69
+ addOverride(reuse.packageName, reuse.to, "reuse");
70
+ }
71
+ }
72
+
73
+ for (const fix of fixes) {
74
+ if (!fix.applicable || fix.target === null) continue;
75
+
76
+ for (const change of fix.workspaceChanges) {
77
+ if (!change.workspace) {
78
+ unresolvableChanges.push(
79
+ `${change.packageName} in ${change.requester}`,
80
+ );
81
+ continue;
82
+ }
83
+ manifestEdits.push({
84
+ importerPath: change.workspace.path,
85
+ depType: change.workspace.depType,
86
+ packageName: change.packageName,
87
+ range: change.range,
88
+ to: change.to,
89
+ });
90
+ }
91
+
92
+ for (const member of [...fix.convergentMembers, ...fix.reResolutionSet]) {
93
+ addOverride(member, fix.target, "converge");
94
+ }
95
+ }
96
+
97
+ return {
98
+ manifestEdits,
99
+ overrides: [...overrides.values()].toSorted((a, b) =>
100
+ a.packageName.localeCompare(b.packageName),
101
+ ),
102
+ conflicts,
103
+ unresolvableChanges,
104
+ };
105
+ };
@@ -0,0 +1,136 @@
1
+ import { describe, it } from "bun:test";
2
+ import { ok, strictEqual } from "node:assert/strict";
3
+ import { renderApplyPlan } from "./renderApplyPlan.ts";
4
+ import type { ApplyPlanOptions } from "./renderApplyPlan.ts";
5
+
6
+ const escapeStart = "\u001B[";
7
+
8
+ const render = (
9
+ overrides: Partial<ApplyPlanOptions> = {},
10
+ ): { output: string; changeCount: number } => {
11
+ let output = "";
12
+ const { changeCount } = renderApplyPlan({
13
+ fileChanges: [
14
+ {
15
+ path: "package.json",
16
+ changes: ['"metro": "0.84.5" -> "0.87.0" (devDependencies)'],
17
+ },
18
+ ],
19
+ dedupeCommand: "pnpm-dedupe",
20
+ color: false,
21
+ log: (message = "") => {
22
+ output += `${message}\n`;
23
+ },
24
+ ...overrides,
25
+ });
26
+ return { output, changeCount };
27
+ };
28
+
29
+ const lastLine = (output: string): string =>
30
+ output.trimEnd().split("\n").at(-1)!;
31
+
32
+ describe("renderApplyPlan", () => {
33
+ it("renders the changes per file and counts them", () => {
34
+ const { output, changeCount } = render();
35
+ strictEqual(changeCount, 1);
36
+ ok(output.startsWith("Would apply:\n"));
37
+ ok(output.includes("package.json:"));
38
+ ok(output.includes(' - "metro": "0.84.5" -> "0.87.0" (devDependencies)'));
39
+ strictEqual(
40
+ lastLine(output),
41
+ "1 change in 1 file. Run `pnpm-dedupe` to apply.",
42
+ );
43
+ });
44
+
45
+ it("pluralizes changes and files", () => {
46
+ const { output, changeCount } = render({
47
+ fileChanges: [
48
+ { path: "package.json", changes: ["a", "b"] },
49
+ { path: "packages/app/package.json", changes: ["c"] },
50
+ ],
51
+ });
52
+ strictEqual(changeCount, 3);
53
+ strictEqual(
54
+ lastLine(output),
55
+ "3 changes in 2 files. Run `pnpm-dedupe` to apply.",
56
+ );
57
+ });
58
+
59
+ it("marks a file that is only touched for the run", () => {
60
+ const { output } = render({
61
+ fileChanges: [
62
+ {
63
+ path: "pnpm-workspace.yaml",
64
+ transient: "removed once the result is verified",
65
+ changes: ['"metro@": "0.87.0" (converge)'],
66
+ },
67
+ ],
68
+ });
69
+ ok(
70
+ output.includes(
71
+ "pnpm-workspace.yaml (transient, removed once the result is verified):",
72
+ ),
73
+ );
74
+ });
75
+
76
+ it("skips a file with no change of its own", () => {
77
+ const { output, changeCount } = render({
78
+ fileChanges: [
79
+ { path: "package.json", changes: ["a"] },
80
+ { path: "pnpm-workspace.yaml", changes: [] },
81
+ ],
82
+ });
83
+ strictEqual(changeCount, 1);
84
+ ok(!output.includes("pnpm-workspace.yaml"));
85
+ ok(lastLine(output).startsWith("1 change in 1 file."));
86
+ });
87
+
88
+ it("reports what the plan gave up on", () => {
89
+ const { output } = render({
90
+ skipped: ["metro-config: keeping 0.87.0, ignoring 0.84.5"],
91
+ });
92
+ ok(output.includes("Skipped:"));
93
+ ok(output.includes(" - metro-config: keeping 0.87.0, ignoring 0.84.5"));
94
+ });
95
+
96
+ it("reports what the package manager would still change", () => {
97
+ const { output } = render({
98
+ packageManagerResiduals: "`pnpm dedupe` would also change the lockfile.",
99
+ });
100
+ ok(output.includes("`pnpm dedupe` would also change the lockfile."));
101
+ });
102
+
103
+ it("says nothing to do when there is no change and no residual", () => {
104
+ const { output, changeCount } = render({ fileChanges: [] });
105
+ strictEqual(changeCount, 0);
106
+ strictEqual(output, "Nothing to dedupe.\n");
107
+ });
108
+
109
+ it("still points at the command when only the package manager would act", () => {
110
+ const { output, changeCount } = render({
111
+ fileChanges: [],
112
+ packageManagerResiduals: "`pnpm dedupe` would also change the lockfile.",
113
+ });
114
+ strictEqual(changeCount, 0);
115
+ ok(!output.includes("Would apply:"));
116
+ strictEqual(lastLine(output), "Run `pnpm-dedupe` to apply.");
117
+ });
118
+
119
+ it("reports what was skipped even with nothing to apply", () => {
120
+ const { output } = render({
121
+ fileChanges: [],
122
+ skipped: ["metro in packages/app: no workspace file recorded for it"],
123
+ });
124
+ ok(
125
+ output.includes(
126
+ " - metro in packages/app: no workspace file recorded for it",
127
+ ),
128
+ );
129
+ ok(output.includes("Nothing to dedupe."));
130
+ });
131
+
132
+ it("emits no escape codes with color off, and some with it on", () => {
133
+ ok(!render().output.includes(escapeStart));
134
+ ok(render({ color: true }).output.includes(escapeStart));
135
+ });
136
+ });
@@ -0,0 +1,95 @@
1
+ import { createColorize, shouldColorize } from "./reportColors.ts";
2
+ import { plural } from "./reportText.ts";
3
+
4
+ export interface ApplyPlanFileChange {
5
+ // repo-relative
6
+ path: string;
7
+ // why the file is only touched for the duration of the run, when it is
8
+ transient?: string;
9
+ // one line per change, worded by the caller: only it knows the manifest
10
+ // shape the change lands in
11
+ changes: string[];
12
+ }
13
+
14
+ export interface ApplyPlanOptions {
15
+ fileChanges: ApplyPlanFileChange[];
16
+ // what the plan gave up on, and why
17
+ skipped?: string[];
18
+ // what the package manager's own dedupe would still do on top
19
+ packageManagerResiduals?: string;
20
+ dedupeCommand: string;
21
+ color?: boolean;
22
+ log?: (message?: string) => void;
23
+ }
24
+
25
+ export interface ApplyPlanSummary {
26
+ changeCount: number;
27
+ }
28
+
29
+ /**
30
+ * The plan a dedupe run would apply, shared by `--check` and `--dry-run`. The
31
+ * returned `changeCount` is what `--check` gates its exit code on.
32
+ */
33
+ export const renderApplyPlan = ({
34
+ fileChanges,
35
+ skipped = [],
36
+ packageManagerResiduals,
37
+ dedupeCommand,
38
+ color: colorEnabled = shouldColorize(),
39
+ log = console.log,
40
+ }: ApplyPlanOptions): ApplyPlanSummary => {
41
+ const color = createColorize(colorEnabled);
42
+ const changeCount = fileChanges.reduce(
43
+ (total, file) => total + file.changes.length,
44
+ 0,
45
+ );
46
+
47
+ if (changeCount === 0 && packageManagerResiduals === undefined) {
48
+ for (const entry of skipped) {
49
+ log(` - ${color("red", entry)}`);
50
+ }
51
+ log("Nothing to dedupe.");
52
+ return { changeCount };
53
+ }
54
+
55
+ if (changeCount > 0) {
56
+ log(color("dim", "Would apply:"));
57
+
58
+ for (const file of fileChanges) {
59
+ if (file.changes.length === 0) continue;
60
+ const suffix =
61
+ file.transient === undefined
62
+ ? ""
63
+ : color("dim", ` (transient, ${file.transient})`);
64
+ log();
65
+ log(`${color("bold", file.path)}${suffix}:`);
66
+ for (const change of file.changes) {
67
+ log(` - ${change}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ if (skipped.length > 0) {
73
+ log();
74
+ log(color("dim", "Skipped:"));
75
+ for (const entry of skipped) {
76
+ log(` - ${color("red", entry)}`);
77
+ }
78
+ }
79
+
80
+ if (packageManagerResiduals !== undefined) {
81
+ log();
82
+ log(packageManagerResiduals);
83
+ }
84
+
85
+ const touchedFiles = fileChanges.filter((file) => file.changes.length > 0);
86
+ const counts =
87
+ changeCount === 0
88
+ ? ""
89
+ : `${plural(changeCount, "change", "changes")} in ${plural(touchedFiles.length, "file", "files")}. `;
90
+
91
+ log();
92
+ log(`${counts}Run ${color("cyan", `\`${dedupeCommand}\``)} to apply.`);
93
+
94
+ return { changeCount };
95
+ };
@@ -0,0 +1,121 @@
1
+ import { describe, it } from "bun:test";
2
+ import { ok, strictEqual } from "node:assert/strict";
3
+ import { renderDedupeSummary } from "./renderDedupeSummary.ts";
4
+ import type { DedupeSummaryOptions } from "./renderDedupeSummary.ts";
5
+
6
+ const render = (overrides: Partial<DedupeSummaryOptions> = {}): string => {
7
+ let output = "";
8
+ renderDedupeSummary({
9
+ deduped: [
10
+ { packageName: "semver", before: ["7.5.4", "7.7.1"], after: ["7.7.1"] },
11
+ ],
12
+ remainingDuplicates: 0,
13
+ whyCommand: "bun-why-duplicate",
14
+ color: false,
15
+ log: (message = "") => {
16
+ output += `${message}\n`;
17
+ },
18
+ ...overrides,
19
+ });
20
+ return output;
21
+ };
22
+
23
+ const lines = (output: string): string[] => output.trimEnd().split("\n");
24
+
25
+ describe("renderDedupeSummary", () => {
26
+ it("counts and names both sides, one package per line", () => {
27
+ strictEqual(
28
+ render({
29
+ deduped: [
30
+ {
31
+ packageName: "@typescript-eslint/parser",
32
+ before: ["8.1.0", "8.2.0", "8.3.0"],
33
+ after: ["8.3.0"],
34
+ },
35
+ {
36
+ packageName: "range-parser",
37
+ before: ["1.2.1", "1.3.0"],
38
+ after: ["1.2.1"],
39
+ },
40
+ ],
41
+ }),
42
+ [
43
+ "Deduped 2 packages, 3 copies merged:",
44
+ " @typescript-eslint/parser: 3 versions (8.1.0, 8.2.0, 8.3.0) -> 1 version (8.3.0)",
45
+ " range-parser: 2 versions (1.2.1, 1.3.0) -> 1 version (1.2.1)",
46
+ "No duplicate left.",
47
+ "",
48
+ ].join("\n"),
49
+ );
50
+ });
51
+
52
+ // the counts are what say a copy went away: a bare `1.3.0 -> 1.2.1` reads as
53
+ // a downgrade the tool chose
54
+ it("keeps the surviving version alongside its count", () => {
55
+ ok(
56
+ render().includes(
57
+ " semver: 2 versions (7.5.4, 7.7.1) -> 1 version (7.7.1)\n",
58
+ ),
59
+ );
60
+ });
61
+
62
+ it("names what is left of a package only partly collapsed", () => {
63
+ ok(
64
+ render({
65
+ deduped: [
66
+ {
67
+ packageName: "react",
68
+ before: ["17.0.2", "18.2.0", "19.0.0"],
69
+ after: ["18.2.0", "19.0.0"],
70
+ },
71
+ ],
72
+ remainingDuplicates: 1,
73
+ }).includes(
74
+ " react: 3 versions (17.0.2, 18.2.0, 19.0.0) -> 2 versions (18.2.0, 19.0.0)\n",
75
+ ),
76
+ );
77
+ });
78
+
79
+ it("summarises a package resolved more times than the line can hold", () => {
80
+ ok(
81
+ render({
82
+ deduped: [
83
+ {
84
+ packageName: "@types/node",
85
+ before: [
86
+ "18.0.0",
87
+ "20.0.0",
88
+ "20.1.0",
89
+ "22.0.0",
90
+ "22.1.0",
91
+ "24.0.0",
92
+ ],
93
+ after: ["24.0.0"],
94
+ },
95
+ ],
96
+ }).includes(
97
+ "6 versions (18.0.0, 20.0.0, 20.1.0, 22.0.0, 22.1.0, +1 more) -> 1 version (24.0.0)",
98
+ ),
99
+ );
100
+ });
101
+
102
+ it("points at the why command for what is left", () => {
103
+ strictEqual(
104
+ lines(render({ remainingDuplicates: 3 })).at(-1),
105
+ "3 duplicates left — run `bun-why-duplicate` to see them.",
106
+ );
107
+ strictEqual(
108
+ lines(render({ remainingDuplicates: 1 })).at(-1),
109
+ "1 duplicate left — run `bun-why-duplicate` to see it.",
110
+ );
111
+ });
112
+
113
+ // the caller has its own wording for a run that changed nothing
114
+ it("prints nothing when nothing was deduped", () => {
115
+ strictEqual(render({ deduped: [], remainingDuplicates: 4 }), "");
116
+ });
117
+
118
+ it("colours the versions when asked", () => {
119
+ ok(render({ color: true }).includes("["));
120
+ });
121
+ });
@@ -0,0 +1,78 @@
1
+ import { stylePackageName } from "./packageStyles.ts";
2
+ import { createColorize, shouldColorize } from "./reportColors.ts";
3
+ import type { Colorize, ReportStyle } from "./reportColors.ts";
4
+ import { columnWidth, listText, padTo, plural } from "./reportText.ts";
5
+ import type { DedupedPackage } from "./versionsSnapshot.ts";
6
+
7
+ export interface DedupeSummaryOptions {
8
+ deduped: DedupedPackage[];
9
+ // packages the lockfile still resolves more than once, counted as
10
+ // `why-duplicate` counts them so the two commands agree
11
+ remainingDuplicates: number;
12
+ whyCommand: string;
13
+ color?: boolean;
14
+ log?: (message?: string) => void;
15
+ }
16
+
17
+ // A package resolved more times than the shared cap is summarised: the line
18
+ // says what it collapsed to, not the whole census.
19
+ const versionList = (
20
+ color: Colorize,
21
+ versions: string[],
22
+ style: ReportStyle,
23
+ ): string =>
24
+ `${plural(versions.length, "version", "versions")} (${listText({
25
+ items: versions,
26
+ render: (version) => color(style, version),
27
+ more: (rest) => color("dim", `, +${rest} more`),
28
+ })})`;
29
+
30
+ /**
31
+ * What a dedupe run actually collapsed, one line per package. Shared by every
32
+ * tool so the three of them report a run the same way; the caller still says
33
+ * what it wrote, which only it knows.
34
+ *
35
+ * Both sides are counted and named: `2 versions (1.2.1, 1.3.0) -> 1 version
36
+ * (1.2.1)` is the copy that went away, where a bare `1.3.0 -> 1.2.1` reads as a
37
+ * downgrade the tool decided on its own.
38
+ *
39
+ * Nothing is printed when nothing was deduped: the caller has its own wording
40
+ * for that, and an empty table reads as a failure it may not be.
41
+ */
42
+ export const renderDedupeSummary = ({
43
+ deduped,
44
+ remainingDuplicates,
45
+ whyCommand,
46
+ color: colorEnabled = shouldColorize(),
47
+ log = console.log,
48
+ }: DedupeSummaryOptions): void => {
49
+ if (deduped.length === 0) return;
50
+
51
+ const color = createColorize(colorEnabled);
52
+ const mergedCount = deduped.reduce(
53
+ (total, entry) => total + (entry.before.length - entry.after.length),
54
+ 0,
55
+ );
56
+
57
+ log(
58
+ `${color("bold", "Deduped")} ${plural(deduped.length, "package", "packages")}, ${plural(mergedCount, "copy", "copies")} merged:`,
59
+ );
60
+
61
+ // tighter than the shared budget: two version lists follow the name here
62
+ const width = columnWidth(
63
+ deduped.map((entry) => entry.packageName),
64
+ 34,
65
+ );
66
+
67
+ for (const { packageName, before, after } of deduped) {
68
+ log(
69
+ ` ${stylePackageName(color, packageName)}:${padTo(width, packageName)} ${versionList(color, before, "yellow")} -> ${versionList(color, after, "green")}`,
70
+ );
71
+ }
72
+
73
+ log(
74
+ remainingDuplicates === 0
75
+ ? "No duplicate left."
76
+ : `${plural(remainingDuplicates, "duplicate", "duplicates")} left — run ${color("cyan", `\`${whyCommand}\``)} to see ${remainingDuplicates === 1 ? "it" : "them"}.`,
77
+ );
78
+ };