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,645 @@
1
+ import semver from "semver";
2
+ import { clusterLabel } from "./clusterLabel.ts";
3
+ import type {
4
+ ClusterExternalConstraint,
5
+ ClusterFix,
6
+ } from "./identifyLockstepClusterFixes.ts";
7
+ import { stylePackageName, stylePackageReference } from "./packageStyles.ts";
8
+ import { createColorize, shouldColorize } from "./reportColors.ts";
9
+ import type { Colorize } from "./reportColors.ts";
10
+ import { columnWidth, listText, padTo, plural } from "./reportText.ts";
11
+
12
+ export interface DuplicateResolutionView {
13
+ resolution: string;
14
+ // the npm version this resolution carries, when it has one. Dependents are
15
+ // grouped under it and merges are named by it; a resolution without one — a
16
+ // git url, a tarball, a `file:` — stands on its own reference instead.
17
+ version?: string;
18
+ installations: string[];
19
+ }
20
+
21
+ export interface DuplicateDependentView {
22
+ requester: string;
23
+ range: string;
24
+ // version this requester actually got, which is what files it under one of
25
+ // the resolutions. A lockfile that only stores resolved versions leaves it
26
+ // undefined, and the requester is then listed as unattributed rather than
27
+ // silently dropped.
28
+ resolvedVersion?: string;
29
+ // the resolution this requester got, for the copies that have no npm version
30
+ // to be filed under — a `patch:` install, a tarball, a git url. Authoritative
31
+ // when set: such a requester is not holding the sole npm version either.
32
+ resolvedResolution?: string;
33
+ // the range is declared as a peer: it constrains the version without the
34
+ // requester holding a copy of its own. Marked after the range rather than
35
+ // appended to the requester, which would push long names past the column
36
+ // budget and leave the group ragged.
37
+ peer?: boolean;
38
+ }
39
+
40
+ // How a duplicate would collapse: the resolutions that go away, and the one
41
+ // they merge into.
42
+ export interface DuplicateDedupeView {
43
+ from: string[];
44
+ to: string;
45
+ // spelled out when the target is not the highest of them: a downgrade is a
46
+ // decision, not a detail
47
+ direction?: ClusterFix["direction"];
48
+ }
49
+
50
+ export interface DuplicatePackageView {
51
+ packageName: string;
52
+ resolutions: DuplicateResolutionView[];
53
+ dependents: DuplicateDependentView[];
54
+ dedupe: DuplicateDedupeView[];
55
+ }
56
+
57
+ // `duplicates` for the report that dropped whatever resolves once, `matches`
58
+ // for the one that kept it
59
+ export type DuplicatesReportTitle = "duplicates" | "matches";
60
+
61
+ export interface DuplicatesReportOptions {
62
+ title: DuplicatesReportTitle;
63
+ packages: DuplicatePackageView[];
64
+ // headline replacing the count line, for a report whose packages are not
65
+ // duplicates and would otherwise read as "No duplicates found" and nothing
66
+ notice?: string;
67
+ // every package in the lockfile, not just the ones listed here
68
+ totalDependencies: number;
69
+ clusterFixes?: ClusterFix[];
70
+ dedupeCommand: string;
71
+ // the `why-duplicate` command, for the line that points at `--details`
72
+ whyCommand?: string;
73
+ // one line per package by default; the whole tree of dependents when set
74
+ details?: boolean;
75
+ color?: boolean;
76
+ log?: (message?: string) => void;
77
+ }
78
+
79
+ const clusterExplanation = [
80
+ "A lockstep cluster is a family of packages published together: each member",
81
+ "pins its siblings at its own version, so a duplicate inside the family only",
82
+ "disappears when the whole family moves to a single version.",
83
+ ];
84
+
85
+ const unattributedLabel = "(resolved version unknown)";
86
+
87
+ const directionLabel = (
88
+ direction: ClusterFix["direction"] | undefined,
89
+ ): string => {
90
+ if (direction === "down") return " (downgrade)";
91
+ if (direction === "up") return " (upgrade)";
92
+ return "";
93
+ };
94
+
95
+ const compareVersionsDescending = (
96
+ versionA: string,
97
+ versionB: string,
98
+ ): number =>
99
+ semver.valid(versionA) !== null && semver.valid(versionB) !== null
100
+ ? semver.rcompare(versionA, versionB)
101
+ : versionA.localeCompare(versionB);
102
+
103
+ interface ResolutionGroup {
104
+ label: string;
105
+ // undefined for a resolution that carries no npm version
106
+ version: string | undefined;
107
+ installations: string[];
108
+ dependents: DuplicateDependentView[];
109
+ // the version this group would collapse into, when it would
110
+ mergesInto: string | undefined;
111
+ direction: ClusterFix["direction"] | undefined;
112
+ }
113
+
114
+ interface ClusterConvergence {
115
+ target: string;
116
+ direction: ClusterFix["direction"];
117
+ }
118
+
119
+ interface PackageAnalysis {
120
+ groups: ResolutionGroup[];
121
+ unattributed: DuplicateDependentView[];
122
+ // what the package is left resolved to once the merges are applied, highest
123
+ // first. A cluster pass can reach a version the lockfile does not carry, so
124
+ // this is not a subset of the groups above.
125
+ after: string[];
126
+ }
127
+
128
+ const toGroups = (view: DuplicatePackageView): ResolutionGroup[] => {
129
+ const byLabel = new Map<string, ResolutionGroup>();
130
+
131
+ for (const { resolution, version, installations } of view.resolutions) {
132
+ const label = version ?? resolution;
133
+ const group = byLabel.get(label);
134
+ // several resolutions of one version — a peer context, an extra install
135
+ // path — are one version to the reader, and their locations are the detail
136
+ if (group) {
137
+ group.installations.push(...installations);
138
+ continue;
139
+ }
140
+ byLabel.set(label, {
141
+ label,
142
+ version,
143
+ installations: [...installations],
144
+ dependents: [],
145
+ mergesInto: undefined,
146
+ direction: undefined,
147
+ });
148
+ }
149
+
150
+ return [...byLabel.values()].toSorted((groupA, groupB) => {
151
+ if (groupA.version === undefined || groupB.version === undefined) {
152
+ return groupA.version === groupB.version
153
+ ? 0
154
+ : Number(groupA.version === undefined) -
155
+ Number(groupB.version === undefined);
156
+ }
157
+ return compareVersionsDescending(groupA.version, groupB.version);
158
+ });
159
+ };
160
+
161
+ // A resolution with no npm version — a `patch:` install, a tarball, a git url —
162
+ // is its own group, labelled by the resolution string. A requester that declared
163
+ // it is filed there by that same string, there being no version to file it under.
164
+ const byResolutionOf = (
165
+ view: DuplicatePackageView,
166
+ groups: ResolutionGroup[],
167
+ ): Map<string, ResolutionGroup> => {
168
+ const byLabel = new Map(groups.map((group) => [group.label, group]));
169
+ return new Map(
170
+ view.resolutions.flatMap(({ resolution, version }) => {
171
+ const group = byLabel.get(version ?? resolution);
172
+ return group ? [[resolution, group] as const] : [];
173
+ }),
174
+ );
175
+ };
176
+
177
+ const byVersionOf = (groups: ResolutionGroup[]): Map<string, ResolutionGroup> =>
178
+ new Map(
179
+ groups
180
+ .filter((group) => group.version !== undefined)
181
+ .map((group) => [group.version!, group]),
182
+ );
183
+
184
+ const applyMerges = (
185
+ byVersion: Map<string, ResolutionGroup>,
186
+ view: DuplicatePackageView,
187
+ cluster: ClusterConvergence | undefined,
188
+ ): void => {
189
+ for (const { from, to, direction } of view.dedupe) {
190
+ for (const version of from) {
191
+ const group = byVersion.get(version);
192
+ if (!group) continue;
193
+ group.mergesInto = to;
194
+ group.direction = direction;
195
+ }
196
+ }
197
+
198
+ // A family only converges as a whole, so a member the cluster pass covers
199
+ // collapses onto the family's target even though nothing about the package
200
+ // alone says so.
201
+ if (view.dedupe.length > 0 || cluster === undefined) return;
202
+ if (!byVersion.has(cluster.target)) return;
203
+ for (const group of byVersion.values()) {
204
+ if (group.version === cluster.target) continue;
205
+ group.mergesInto = cluster.target;
206
+ group.direction = cluster.direction;
207
+ }
208
+ };
209
+
210
+ const analysePackage = (
211
+ view: DuplicatePackageView,
212
+ cluster: ClusterConvergence | undefined,
213
+ ): PackageAnalysis => {
214
+ const groups = toGroups(view);
215
+ const byVersion = byVersionOf(groups);
216
+ const byResolution = byResolutionOf(view, groups);
217
+
218
+ // A package the lockfile resolves once leaves nothing to attribute: whatever
219
+ // got a copy got that one. Peer ranges arrive with no resolved version of
220
+ // their own — the version a peer requester was handed is its parent's
221
+ // business — and filing twenty of them under "(resolved version unknown)"
222
+ // next to a single resolution reads as missing data instead of the one thing
223
+ // that is certain. A range the sole version does not satisfy still belongs
224
+ // there: an unmet peer is a warning the package manager already gave, not a
225
+ // second copy. Only a lone versioned group qualifies — a second resolution,
226
+ // even an unversioned git url or tarball, makes it a guess again.
227
+ const soleGroup =
228
+ groups.length === 1 && groups[0]!.version !== undefined
229
+ ? groups[0]
230
+ : undefined;
231
+
232
+ const groupOf = (
233
+ dependent: DuplicateDependentView,
234
+ ): ResolutionGroup | undefined => {
235
+ if (dependent.resolvedResolution !== undefined) {
236
+ return byResolution.get(dependent.resolvedResolution);
237
+ }
238
+ if (dependent.resolvedVersion !== undefined) {
239
+ return byVersion.get(dependent.resolvedVersion) ?? soleGroup;
240
+ }
241
+ return soleGroup;
242
+ };
243
+
244
+ const unattributed: DuplicateDependentView[] = [];
245
+ for (const dependent of view.dependents) {
246
+ const group = groupOf(dependent);
247
+ if (group) group.dependents.push(dependent);
248
+ else unattributed.push(dependent);
249
+ }
250
+
251
+ applyMerges(byVersion, view, cluster);
252
+
253
+ const after = [
254
+ ...new Set(groups.map((group) => group.mergesInto ?? group.label)),
255
+ ].toSorted(compareVersionsDescending);
256
+
257
+ return { groups, unattributed, after };
258
+ };
259
+
260
+ const merges = ({ groups }: PackageAnalysis): boolean =>
261
+ groups.some((group) => group.mergesInto !== undefined);
262
+
263
+ /**
264
+ * What the package is left with once the merges are applied. `deduped to 3
265
+ * versions` always means three remain — never three merged away, which is the
266
+ * one reading a dedupe report cannot afford to be ambiguous about.
267
+ */
268
+ const verdictText = (color: Colorize, analysis: PackageAnalysis): string => {
269
+ if (!merges(analysis)) return "";
270
+
271
+ const { groups, after } = analysis;
272
+ const direction = groups.find(
273
+ (group) => group.mergesInto !== undefined,
274
+ )?.direction;
275
+
276
+ if (after.length === 1) {
277
+ return `, can be deduped to ${color("green", after[0]!)}${directionLabel(direction)}`;
278
+ }
279
+ return `, can be deduped to ${color("green", plural(after.length, "version", "versions"))}`;
280
+ };
281
+
282
+ const renderSummaryLine = (
283
+ view: DuplicatePackageView,
284
+ analysis: PackageAnalysis,
285
+ nameWidth: number,
286
+ color: Colorize,
287
+ log: (message?: string) => void,
288
+ ): void => {
289
+ const { groups } = analysis;
290
+ const census = listText({
291
+ items: groups.map((group) => group.label),
292
+ render: (label) => color("yellow", label),
293
+ more: (rest) => color("dim", `, +${rest} more`),
294
+ });
295
+
296
+ const installations = groups.reduce(
297
+ (total, group) => total + group.installations.length,
298
+ 0,
299
+ );
300
+ // with a single version the census says nothing about why the package is
301
+ // listed; the install paths are what make it a duplicate
302
+ const paths =
303
+ groups.length === 1 && installations > 1
304
+ ? color("dim", ` installed at ${plural(installations, "path", "paths")}`)
305
+ : "";
306
+
307
+ log(
308
+ `- ${stylePackageName(color, view.packageName)}${padTo(nameWidth, view.packageName)} resolved to ${plural(groups.length, "version", "versions")} (${census})${paths}${verdictText(color, analysis)}`,
309
+ );
310
+ };
311
+
312
+ const dependentLine = (
313
+ dependent: DuplicateDependentView,
314
+ requesterWidth: number,
315
+ color: Colorize,
316
+ ): string =>
317
+ ` - ${stylePackageReference(color, dependent.requester)}${padTo(requesterWidth, dependent.requester)} requires ${color("yellow", `"${dependent.range}"`)}${dependent.peer ? color("dim", " (peer)") : ""}`;
318
+
319
+ const renderGroup = (
320
+ group: ResolutionGroup,
321
+ requesterWidth: number,
322
+ color: Colorize,
323
+ log: (message?: string) => void,
324
+ ): void => {
325
+ const merge =
326
+ group.mergesInto === undefined
327
+ ? ""
328
+ : ` ${color("dim", "can be deduped to")} ${color("green", group.mergesInto)}${directionLabel(group.direction)}`;
329
+ log(` ${stylePackageReference(color, group.label)}${merge}`);
330
+
331
+ for (const dependent of group.dependents) {
332
+ log(dependentLine(dependent, requesterWidth, color));
333
+ }
334
+
335
+ if (group.dependents.length === 0) {
336
+ log(` ${color("dim", "(no semver range recorded for it)")}`);
337
+ }
338
+
339
+ // install paths are long, and the reader is here for the ranges: a handful
340
+ // locates the copy, the rest is counted
341
+ if (group.installations.length > 1) {
342
+ log(
343
+ ` ${color("dim", "installed at:")} ${listText({
344
+ items: group.installations,
345
+ render: (location) => stylePackageReference(color, location),
346
+ limit: 3,
347
+ more: (rest) => color("dim", `, +${rest} more`),
348
+ })}`,
349
+ );
350
+ }
351
+ };
352
+
353
+ const renderPackageDetails = (
354
+ view: DuplicatePackageView,
355
+ analysis: PackageAnalysis,
356
+ clusterIndex: number | undefined,
357
+ color: Colorize,
358
+ log: (message?: string) => void,
359
+ ): void => {
360
+ const { groups, unattributed } = analysis;
361
+ const cluster =
362
+ clusterIndex === undefined
363
+ ? ""
364
+ : color("dim", ` (cluster ${clusterIndex})`);
365
+
366
+ log();
367
+ log(
368
+ `${stylePackageName(color, view.packageName, ["bold", "packageName"])} — ${plural(groups.length, "version", "versions")}${verdictText(color, analysis)}${cluster}`,
369
+ );
370
+
371
+ const requesterWidth = columnWidth(
372
+ view.dependents.map((dependent) => dependent.requester),
373
+ );
374
+
375
+ for (const group of groups) {
376
+ renderGroup(group, requesterWidth, color, log);
377
+ }
378
+
379
+ if (unattributed.length > 0) {
380
+ log(` ${color("dim", unattributedLabel)}`);
381
+ for (const dependent of unattributed) {
382
+ log(dependentLine(dependent, requesterWidth, color));
383
+ }
384
+ }
385
+ };
386
+
387
+ // a workspace importer has no npm name; it is the one requester named by role
388
+ const constraintName = (requesterName: string | undefined): string =>
389
+ requesterName ?? "workspace";
390
+
391
+ const constraintText = (
392
+ color: Colorize,
393
+ requesterName: string | undefined,
394
+ ): string =>
395
+ requesterName === undefined
396
+ ? color("dim", "workspace")
397
+ : stylePackageName(color, requesterName);
398
+
399
+ const nameList = (color: Colorize, names: string[]): string =>
400
+ names.map((name) => stylePackageName(color, name)).join(", ");
401
+
402
+ // highest version first, whatever could not be attributed to one last
403
+ const compareGroupLabels = (labelA: string, labelB: string): number => {
404
+ if (labelA === labelB) return 0;
405
+ if (labelA === unattributedLabel) return 1;
406
+ if (labelB === unattributedLabel) return -1;
407
+ return compareVersionsDescending(labelA, labelB);
408
+ };
409
+
410
+ /**
411
+ * The third-party ranges bearing on the family, filed under the version each of
412
+ * them resolved to — the same shape a package's own resolutions take, because it
413
+ * answers the same question: which requester keeps which copy alive.
414
+ */
415
+ const renderExternalResolutions = (
416
+ constraints: ClusterExternalConstraint[],
417
+ color: Colorize,
418
+ log: (message?: string) => void,
419
+ ): void => {
420
+ const byVersion = new Map<string, ClusterExternalConstraint[]>();
421
+ for (const constraint of constraints) {
422
+ const label = constraint.resolvedVersion ?? unattributedLabel;
423
+ const group = byVersion.get(label);
424
+ if (group) group.push(constraint);
425
+ else byVersion.set(label, [constraint]);
426
+ }
427
+
428
+ const labels = [...byVersion.keys()].toSorted(compareGroupLabels);
429
+
430
+ const width = columnWidth(
431
+ constraints.map((constraint) => constraintName(constraint.requesterName)),
432
+ );
433
+
434
+ log(` ${color("dim", "Resolutions (external):")}`);
435
+ for (const label of labels) {
436
+ log(
437
+ ` ${label === unattributedLabel ? color("dim", label) : stylePackageReference(color, label)}`,
438
+ );
439
+ for (const constraint of byVersion.get(label)!) {
440
+ log(
441
+ ` - ${constraintText(color, constraint.requesterName)}${padTo(width, constraintName(constraint.requesterName))} requires ${stylePackageName(color, constraint.packageName)} ${color("yellow", `"${constraint.range}"`)}`,
442
+ );
443
+ }
444
+ }
445
+ };
446
+
447
+ const renderCluster = (
448
+ fix: ClusterFix,
449
+ index: number,
450
+ color: Colorize,
451
+ log: (message?: string) => void,
452
+ ): void => {
453
+ const fixable = fix.applicable ? fix.convergentMembers.length : 0;
454
+ const counts = [
455
+ plural(fix.members.length, "package", "packages"),
456
+ `${fix.duplicatedMembers.length} duplicated`,
457
+ `${fixable} fixable`,
458
+ ].join(", ");
459
+
460
+ log();
461
+ log(
462
+ `cluster ${index} — ${color(["bold", "cyan"], clusterLabel(fix.members))} [${counts}]:`,
463
+ );
464
+
465
+ log(` ${color("dim", "Members:")}`);
466
+ const width = columnWidth(fix.members);
467
+ for (const member of fix.members) {
468
+ const installed = fix.memberVersions[member];
469
+ const versions = color("yellow", installed?.versions.join(", ") ?? "");
470
+ const nonNpm =
471
+ installed && installed.nonNpmCount > 0
472
+ ? color("dim", ` (+${installed.nonNpmCount} non-npm)`)
473
+ : "";
474
+ log(
475
+ ` - ${stylePackageName(color, member)}${padTo(width, member)} ${versions}${nonNpm}`,
476
+ );
477
+ }
478
+
479
+ if (fix.applicable && fix.target !== null) {
480
+ log(
481
+ ` ${color("dim", "Dedupe:")} ${color("green", fix.target)}${directionLabel(fix.direction)}`,
482
+ );
483
+ if (fix.driverMembers.length > 0) {
484
+ const followers = fix.convergentMembers.length - fix.driverMembers.length;
485
+ const follow =
486
+ followers > 0
487
+ ? color(
488
+ "dim",
489
+ ` (${plural(followers, "member follows", "members follow")})`,
490
+ )
491
+ : "";
492
+ log(
493
+ ` ${color("dim", "Driven by:")} ${nameList(color, fix.driverMembers)}${follow}`,
494
+ );
495
+ }
496
+ // nothing pins these, so the package manager picks their version: the
497
+ // fixable count above is not a guarantee for them
498
+ if (fix.floatingMembers.length > 0) {
499
+ log(
500
+ ` ${color("dim", "Resolver picks:")} ${nameList(color, fix.floatingMembers)}`,
501
+ );
502
+ }
503
+ }
504
+
505
+ if (fix.excludedMembers.length > 0) {
506
+ log(` ${color("dim", "Remaining duplicates:")}`);
507
+ for (const excluded of fix.excludedMembers) {
508
+ const blockers = excluded.blockedBy
509
+ .map(
510
+ (constraint) =>
511
+ `${constraintText(color, constraint.requesterName)} requires ${color("yellow", `"${constraint.range}"`)}`,
512
+ )
513
+ .join(", ");
514
+ log(
515
+ ` - ${stylePackageName(color, excluded.packageName, ["red"])}: ${blockers}`,
516
+ );
517
+ }
518
+ }
519
+
520
+ if (fix.reuseFixes.length > 0) {
521
+ log(
522
+ ` ${color("dim", `Open ranges not reusing the pinned ${fix.anchor ?? ""}:`)}`,
523
+ );
524
+ for (const reuse of fix.reuseFixes) {
525
+ log(
526
+ ` - ${constraintText(color, reuse.requesterName)} requires ${stylePackageName(color, reuse.packageName)} ${color("yellow", `"${reuse.range}"`)}, resolved ${color("yellow", reuse.from)} -> would pin ${color("green", reuse.to)}`,
527
+ );
528
+ }
529
+ }
530
+
531
+ if (fix.externalConstraints.length > 0) {
532
+ renderExternalResolutions(fix.externalConstraints, color, log);
533
+ }
534
+ };
535
+
536
+ // What a family converges its members onto, for the packages whose own
537
+ // resolutions say nothing about it.
538
+ const clusterConvergences = (
539
+ clusterFixes: ClusterFix[],
540
+ ): Map<string, ClusterConvergence> => {
541
+ const convergences = new Map<string, ClusterConvergence>();
542
+ for (const fix of clusterFixes) {
543
+ if (!fix.applicable || fix.target === null) continue;
544
+ for (const member of fix.convergentMembers) {
545
+ if (convergences.has(member)) continue;
546
+ convergences.set(member, {
547
+ target: fix.target,
548
+ direction: fix.direction,
549
+ });
550
+ }
551
+ }
552
+ return convergences;
553
+ };
554
+
555
+ export const renderDuplicatesReport = ({
556
+ title,
557
+ packages,
558
+ notice,
559
+ totalDependencies,
560
+ clusterFixes = [],
561
+ dedupeCommand,
562
+ whyCommand,
563
+ details = false,
564
+ color: colorEnabled = shouldColorize(),
565
+ log = console.log,
566
+ }: DuplicatesReportOptions): void => {
567
+ const color = createColorize(colorEnabled);
568
+ const titleSingular = title === "duplicates" ? "duplicate" : "match";
569
+
570
+ if (notice !== undefined) {
571
+ log(notice);
572
+ } else if (packages.length === 0) {
573
+ log("No duplicates found");
574
+ } else {
575
+ log(`Found ${plural(packages.length, titleSingular, title)}:`);
576
+ }
577
+
578
+ // a cluster of one carries no family to explain: it is rendered as the
579
+ // package's own fix, in its own block
580
+ const clusters = clusterFixes.filter(
581
+ (fix) => fix.duplicatedMembers.length > 0 && fix.members.length > 1,
582
+ );
583
+ const clusterIndexes = new Map<string, number>();
584
+ clusters.forEach((fix, index) => {
585
+ for (const member of fix.members) {
586
+ if (!clusterIndexes.has(member)) clusterIndexes.set(member, index + 1);
587
+ }
588
+ });
589
+
590
+ const convergences = clusterConvergences(clusterFixes);
591
+ const analyses = packages.map((view) => ({
592
+ view,
593
+ analysis: analysePackage(view, convergences.get(view.packageName)),
594
+ }));
595
+
596
+ if (details) {
597
+ for (const { view, analysis } of analyses) {
598
+ renderPackageDetails(
599
+ view,
600
+ analysis,
601
+ clusterIndexes.get(view.packageName),
602
+ color,
603
+ log,
604
+ );
605
+ }
606
+
607
+ if (clusters.length > 0) {
608
+ log();
609
+ log(color("dim", "Lockstep clusters:"));
610
+ for (const line of clusterExplanation) {
611
+ log(` ${color("dim", line)}`);
612
+ }
613
+ clusters.forEach((fix, index) => {
614
+ renderCluster(fix, index + 1, color, log);
615
+ });
616
+ }
617
+ } else if (analyses.length > 0) {
618
+ const nameWidth = columnWidth(packages.map((view) => view.packageName));
619
+ log();
620
+ for (const { view, analysis } of analyses) {
621
+ renderSummaryLine(view, analysis, nameWidth, color, log);
622
+ }
623
+ }
624
+
625
+ const dedupable = analyses.filter(({ analysis }) => merges(analysis)).length;
626
+
627
+ log();
628
+ if (dedupable === 0) {
629
+ log(
630
+ `Found ${plural(totalDependencies, "dependency", "dependencies")}, ${plural(packages.length, titleSingular, title)}, 0 dedupable.`,
631
+ );
632
+ } else {
633
+ // Merging a package collapses its own dependency edges too, so a run can
634
+ // remove copies no per-package fix predicted: the count is a floor.
635
+ log(
636
+ `Found ${plural(totalDependencies, "dependency", "dependencies")}, ${plural(packages.length, titleSingular, title)}, at least ${dedupable} dedupable (deduping may remove more). Run ${color("cyan", `\`${dedupeCommand}\``)} to apply.`,
637
+ );
638
+ }
639
+
640
+ if (!details && whyCommand !== undefined && packages.length > 0) {
641
+ log(
642
+ `Run ${color("cyan", `\`${whyCommand} --details\``)} to see every dependent.`,
643
+ );
644
+ }
645
+ };
@@ -0,0 +1,57 @@
1
+ import { afterEach, describe, it } from "bun:test";
2
+ import { ok, strictEqual } from "node:assert/strict";
3
+ import { createColorize, shouldColorize } from "./reportColors.ts";
4
+
5
+ const environment = { ...process.env };
6
+
7
+ afterEach(() => {
8
+ process.env = { ...environment };
9
+ });
10
+
11
+ describe("createColorize", () => {
12
+ it("returns the text untouched when disabled", () => {
13
+ strictEqual(createColorize(false)(["bold", "cyan"], "metro"), "metro");
14
+ });
15
+
16
+ it("wraps the text when enabled", () => {
17
+ const wrapped = createColorize(true)("dim", "metro");
18
+ ok(wrapped.includes("metro"));
19
+ ok(wrapped.length > "metro".length);
20
+ });
21
+ });
22
+
23
+ describe("shouldColorize", () => {
24
+ it("follows the stream when nothing forces the decision", () => {
25
+ delete process.env.FORCE_COLOR;
26
+ delete process.env.NO_COLOR;
27
+ delete process.env.TERM;
28
+ strictEqual(shouldColorize({ isTTY: true }), true);
29
+ strictEqual(shouldColorize({ isTTY: false }), false);
30
+ strictEqual(shouldColorize({}), false);
31
+ });
32
+
33
+ it("honours FORCE_COLOR over a piped stream", () => {
34
+ delete process.env.NO_COLOR;
35
+ process.env.FORCE_COLOR = "1";
36
+ strictEqual(shouldColorize({ isTTY: false }), true);
37
+ });
38
+
39
+ it("ignores FORCE_COLOR=0", () => {
40
+ delete process.env.NO_COLOR;
41
+ process.env.FORCE_COLOR = "0";
42
+ strictEqual(shouldColorize({ isTTY: false }), false);
43
+ });
44
+
45
+ it("honours NO_COLOR over a TTY", () => {
46
+ delete process.env.FORCE_COLOR;
47
+ process.env.NO_COLOR = "1";
48
+ strictEqual(shouldColorize({ isTTY: true }), false);
49
+ });
50
+
51
+ it("refuses a dumb terminal", () => {
52
+ delete process.env.FORCE_COLOR;
53
+ delete process.env.NO_COLOR;
54
+ process.env.TERM = "dumb";
55
+ strictEqual(shouldColorize({ isTTY: true }), false);
56
+ });
57
+ });