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,78 @@
1
+ // Backported to node 22.13: allowed in eslint.config.js.
2
+ import { styleText } from "node:util";
3
+
4
+ // One hue for package identifiers, the scope a shade deeper than the name it
5
+ // prefixes. `styleText` only knows the 16 named colours, hence the raw codes.
6
+ const palette = {
7
+ scope: { hex: "#d75f00", ansi256: 166 },
8
+ packageName: { hex: "#d7875f", ansi256: 173 },
9
+ } as const;
10
+
11
+ export type PaletteStyle = keyof typeof palette;
12
+
13
+ export type ReportStyle =
14
+ | PaletteStyle
15
+ | "bold"
16
+ | "cyan"
17
+ | "dim"
18
+ | "green"
19
+ | "red"
20
+ | "yellow";
21
+
22
+ export type Colorize = (
23
+ styles: ReportStyle | ReportStyle[],
24
+ text: string,
25
+ ) => string;
26
+
27
+ /**
28
+ * `styleText`'s own stream detection always looks at `process.stdout`, and bun
29
+ * has emitted escape codes regardless of it in the past. The bins run under
30
+ * both runtimes and also report on stderr, so the decision is taken here and
31
+ * `styleText` is called with `validateStream: false`.
32
+ */
33
+ export const shouldColorize = (
34
+ stream: { isTTY?: boolean } = process.stdout,
35
+ ): boolean => {
36
+ const { FORCE_COLOR, NO_COLOR, TERM } = process.env;
37
+ if (FORCE_COLOR !== undefined && FORCE_COLOR !== "" && FORCE_COLOR !== "0") {
38
+ return true;
39
+ }
40
+ if (NO_COLOR !== undefined && NO_COLOR !== "") return false;
41
+ if (TERM === "dumb") return false;
42
+ return stream.isTTY === true;
43
+ };
44
+
45
+ const plain: Colorize = (_styles, text) => text;
46
+
47
+ const isPaletteStyle = (style: ReportStyle): style is PaletteStyle =>
48
+ style in palette;
49
+
50
+ // 24-bit when the terminal advertises it, the 256-colour approximation
51
+ // otherwise.
52
+ const paletteCode = (style: PaletteStyle): string => {
53
+ const { hex, ansi256 } = palette[style];
54
+ const { COLORTERM } = process.env;
55
+ if (COLORTERM !== "truecolor" && COLORTERM !== "24bit") {
56
+ return `38;5;${ansi256}`;
57
+ }
58
+ const channels = [1, 3, 5].map((start) =>
59
+ Number.parseInt(hex.slice(start, start + 2), 16),
60
+ );
61
+ return `38;2;${channels.join(";")}`;
62
+ };
63
+
64
+ // The palette colour wraps whatever `styleText` produced: its own reset (`22m`
65
+ // for bold) leaves the colour standing, where the reverse order would not.
66
+ const styled: Colorize = (styles, text) => {
67
+ const requested = Array.isArray(styles) ? styles : [styles];
68
+ const named = requested.filter((style) => !isPaletteStyle(style));
69
+ const inner =
70
+ named.length > 0 ? styleText(named, text, { validateStream: false }) : text;
71
+ const paletteStyle = requested.find(isPaletteStyle);
72
+ return paletteStyle === undefined
73
+ ? inner
74
+ : `\u001B[${paletteCode(paletteStyle)}m${inner}\u001B[39m`;
75
+ };
76
+
77
+ export const createColorize = (enabled: boolean): Colorize =>
78
+ enabled ? styled : plain;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The wording every report shares. Kept in one place so `why-duplicate` and the
3
+ * summary of a dedupe run cannot drift on how they count versions: both mean
4
+ * "this many remain", never "this many were merged".
5
+ */
6
+
7
+ export const plural = (
8
+ count: number,
9
+ singular: string,
10
+ pluralForm: string,
11
+ ): string => `${count} ${count === 1 ? singular : pluralForm}`;
12
+
13
+ /**
14
+ * How wide a left column may get before it starts pushing what follows off the
15
+ * right of the screen. A value longer than the budget keeps its own line width
16
+ * — the column is sized on the longest one that fits, so the outlier overflows
17
+ * alone instead of clamping the column below what every other row needs.
18
+ */
19
+ export const maxColumnWidth = 44;
20
+
21
+ // Beyond this a census stops being readable and the tail is counted instead.
22
+ export const maxItemsListed = 5;
23
+
24
+ export const columnWidth = (
25
+ values: string[],
26
+ budget: number = maxColumnWidth,
27
+ ): number =>
28
+ Math.max(
29
+ 0,
30
+ ...values.map((value) => value.length).filter((length) => length <= budget),
31
+ );
32
+
33
+ // padded on the plain value: the styled one carries escape codes
34
+ export const padTo = (width: number, value: string): string =>
35
+ " ".repeat(Math.max(0, width - value.length));
36
+
37
+ interface ListTextOptions {
38
+ items: string[];
39
+ render?: (item: string) => string;
40
+ limit?: number;
41
+ more: (rest: number) => string;
42
+ }
43
+
44
+ /**
45
+ * `a, b, c, +2 more` — the tail counted rather than dropped, so a truncated
46
+ * list never reads as a complete one.
47
+ */
48
+ export const listText = ({
49
+ items,
50
+ render = (item) => item,
51
+ limit = maxItemsListed,
52
+ more,
53
+ }: ListTextOptions): string => {
54
+ const listed = items.slice(0, limit);
55
+ const rest = items.length - listed.length;
56
+ const suffix = rest === 0 ? "" : more(rest);
57
+ return `${listed.map(render).join(", ")}${suffix}`;
58
+ };
@@ -0,0 +1,67 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
3
+ import { createPackageFilter } from "./createPackageFilter.ts";
4
+ import type { PackageFilterOptions } from "./createPackageFilter.ts";
5
+ import { selectExplainedPackages } from "./selectExplainedPackages.ts";
6
+
7
+ const packagesMap: Record<string, string[]> = {
8
+ metro: ["metro@0.84.5", "metro@0.87.0"],
9
+ lodash: ["lodash@4.17.21"],
10
+ semver: ["semver@7.6.0"],
11
+ };
12
+
13
+ const select = (
14
+ filterOptions: PackageFilterOptions,
15
+ all = false,
16
+ ): ReturnType<typeof selectExplainedPackages<string[]>> =>
17
+ selectExplainedPackages({
18
+ packagesMap,
19
+ filter: createPackageFilter(filterOptions),
20
+ all,
21
+ });
22
+
23
+ describe("selectExplainedPackages", () => {
24
+ it("keeps only the duplicated packages of the selection", () => {
25
+ const { packages, title, notice } = select({});
26
+ deepStrictEqual(Object.keys(packages), ["metro"]);
27
+ strictEqual(title, "duplicates");
28
+ strictEqual(notice, undefined);
29
+ });
30
+
31
+ it("keeps a named package that is not duplicated, to show its dependents", () => {
32
+ const { packages, title, notice } = select({ include: ["lodash"] });
33
+ deepStrictEqual(Object.keys(packages), ["lodash"]);
34
+ strictEqual(title, "matches");
35
+ strictEqual(notice, "lodash is not duplicated. Showing its dependents:");
36
+ });
37
+
38
+ it("names every match when several are selected and none is duplicated", () => {
39
+ const { packages, notice } = select({ include: ["lodash", "semver"] });
40
+ deepStrictEqual(Object.keys(packages), ["lodash", "semver"]);
41
+ ok(notice?.startsWith("None of the 2 matches is duplicated"));
42
+ ok(notice?.includes("lodash, semver"));
43
+ });
44
+
45
+ it("drops the single-version matches as soon as one selection is duplicated", () => {
46
+ const { packages, title, notice } = select({
47
+ include: ["lodash", "metro"],
48
+ });
49
+ deepStrictEqual(Object.keys(packages), ["metro"]);
50
+ strictEqual(title, "duplicates");
51
+ strictEqual(notice, undefined);
52
+ });
53
+
54
+ it("reports nothing for a package the lockfile does not hold", () => {
55
+ const { packages, title, notice } = select({ include: ["missing"] });
56
+ deepStrictEqual(Object.keys(packages), []);
57
+ strictEqual(title, "duplicates");
58
+ strictEqual(notice, undefined);
59
+ });
60
+
61
+ it("keeps every match under --all without a notice", () => {
62
+ const { packages, title, notice } = select({}, true);
63
+ deepStrictEqual(Object.keys(packages), ["metro", "lodash", "semver"]);
64
+ strictEqual(title, "matches");
65
+ strictEqual(notice, undefined);
66
+ });
67
+ });
@@ -0,0 +1,79 @@
1
+ import type { PackageFilter } from "./createPackageFilter.ts";
2
+ import type { DuplicatesReportTitle } from "./renderDuplicatesReport.ts";
3
+ import { listText, plural } from "./reportText.ts";
4
+
5
+ export interface SelectExplainedPackagesOptions<
6
+ Resolutions extends readonly unknown[],
7
+ > {
8
+ // every package the lockfile holds, keyed by name
9
+ packagesMap: Record<string, Resolutions>;
10
+ filter: PackageFilter;
11
+ // keeps packages the lockfile resolves only once, which a listing drops
12
+ all: boolean;
13
+ }
14
+
15
+ export interface ExplainedPackagesSelection<
16
+ Resolutions extends readonly unknown[],
17
+ > {
18
+ packages: Record<string, Resolutions>;
19
+ title: DuplicatesReportTitle;
20
+ // headline replacing "No duplicates found", for the selection kept for its
21
+ // dependents alone
22
+ notice: string | undefined;
23
+ }
24
+
25
+ const singleVersionNotice = (names: string[]): string => {
26
+ const shown = listText({
27
+ items: names,
28
+ more: (rest) => `, +${rest} more`,
29
+ });
30
+ return names.length === 1
31
+ ? `${shown} is not duplicated. Showing its dependents:`
32
+ : `None of the ${plural(names.length, "match", "matches")} is duplicated (${shown}). Showing their dependents:`;
33
+ };
34
+
35
+ /**
36
+ * The packages a `why-duplicate` run reports on. Naming a package is asking why
37
+ * it is duplicated, and the honest answer to "it is not" is its dependents, not
38
+ * an empty report: a selection holding no duplicate is kept whole, and the
39
+ * notice says why it is being shown.
40
+ *
41
+ * `--all` asks for that unconditionally, so it never needs the fallback.
42
+ */
43
+ export const selectExplainedPackages = <
44
+ Resolutions extends readonly unknown[],
45
+ >({
46
+ packagesMap,
47
+ filter,
48
+ all,
49
+ }: SelectExplainedPackagesOptions<Resolutions>): ExplainedPackagesSelection<Resolutions> => {
50
+ const selected = Object.entries(packagesMap).filter(([packageName]) =>
51
+ filter.selects(packageName),
52
+ );
53
+
54
+ if (all) {
55
+ return {
56
+ packages: Object.fromEntries(selected),
57
+ title: "matches",
58
+ notice: undefined,
59
+ };
60
+ }
61
+
62
+ const duplicated = selected.filter(
63
+ ([, resolutions]) => resolutions.length > 1,
64
+ );
65
+
66
+ if (duplicated.length > 0 || selected.length === 0) {
67
+ return {
68
+ packages: Object.fromEntries(duplicated),
69
+ title: "duplicates",
70
+ notice: undefined,
71
+ };
72
+ }
73
+
74
+ return {
75
+ packages: Object.fromEntries(selected),
76
+ title: "matches",
77
+ notice: singleVersionNotice(selected.map(([packageName]) => packageName)),
78
+ };
79
+ };
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type {
3
+ ClusterExternalConstraint,
4
+ ClusterFix,
5
+ PlannedOverride,
6
+ } from "./index.ts";
7
+ import { partitionUnconditionalOverrides } from "./unconditionalOverrides.ts";
8
+
9
+ const constraint = (range: string): ClusterExternalConstraint => ({
10
+ requester: "requester",
11
+ requesterName: "requester",
12
+ packageName: "lib",
13
+ range,
14
+ });
15
+
16
+ const workspaceConstraint = (range: string): ClusterExternalConstraint => ({
17
+ ...constraint(range),
18
+ requester: "package.json in dependencies",
19
+ requesterName: undefined,
20
+ });
21
+
22
+ // only `externalConstraints` is read; the rest is an empty cluster
23
+ const fixWith = (constraints: ClusterExternalConstraint[]): ClusterFix => ({
24
+ members: ["lib"],
25
+ duplicatedMembers: ["lib"],
26
+ memberVersions: {},
27
+ target: null,
28
+ direction: "none",
29
+ convergentMembers: [],
30
+ driverMembers: [],
31
+ excludedMembers: [],
32
+ anchor: null,
33
+ reuseFixes: [],
34
+ floatingMembers: [],
35
+ workspaceChanges: [],
36
+ reResolutionSet: [],
37
+ externalConstraints: constraints,
38
+ needsRoundTrip: false,
39
+ applicable: false,
40
+ });
41
+
42
+ const override: PlannedOverride = {
43
+ packageName: "lib",
44
+ version: "2.0.0",
45
+ reason: "converge",
46
+ };
47
+
48
+ describe("partitionUnconditionalOverrides", () => {
49
+ it("rejects an override a third-party range does not accept", () => {
50
+ const { safe, rejected } = partitionUnconditionalOverrides(
51
+ [fixWith([constraint("^1.0.0")])],
52
+ [override],
53
+ );
54
+
55
+ expect(safe).toEqual([]);
56
+ expect(rejected).toEqual([
57
+ { override, rejectedBy: [constraint("^1.0.0")] },
58
+ ]);
59
+ });
60
+
61
+ it("keeps an override every range accepts", () => {
62
+ const { safe, rejected } = partitionUnconditionalOverrides(
63
+ [fixWith([constraint("^2.0.0")])],
64
+ [override],
65
+ );
66
+
67
+ expect(safe).toEqual([override]);
68
+ expect(rejected).toEqual([]);
69
+ });
70
+
71
+ // `semver.satisfies` answers `false` for a range it cannot read rather than
72
+ // throwing, so a selector naming something other than a version — which says
73
+ // nothing about the version at all — would otherwise reject every override.
74
+ it("keeps an override against a selector semver cannot read", () => {
75
+ const { safe, rejected } = partitionUnconditionalOverrides(
76
+ [
77
+ fixWith([
78
+ constraint("workspace:*"),
79
+ constraint("git+ssh://git@host/r.git#v1"),
80
+ constraint("catalog:default"),
81
+ ]),
82
+ ],
83
+ [override],
84
+ );
85
+
86
+ expect(safe).toEqual([override]);
87
+ expect(rejected).toEqual([]);
88
+ });
89
+
90
+ // A workspace range belongs to the user, who is the one asking for the dedupe.
91
+ it("ignores a workspace constraint", () => {
92
+ const { safe } = partitionUnconditionalOverrides(
93
+ [fixWith([workspaceConstraint("^1.0.0")])],
94
+ [override],
95
+ );
96
+
97
+ expect(safe).toEqual([override]);
98
+ });
99
+ });
@@ -0,0 +1,60 @@
1
+ import semver from "semver";
2
+ import type {
3
+ ClusterExternalConstraint,
4
+ ClusterFix,
5
+ } from "./identifyLockstepClusterFixes.ts";
6
+ import type { PlannedOverride } from "./planClusterApply.ts";
7
+
8
+ export interface RejectedOverride {
9
+ override: PlannedOverride;
10
+ rejectedBy: ClusterExternalConstraint[];
11
+ }
12
+
13
+ export interface PartitionedOverrides {
14
+ safe: PlannedOverride[];
15
+ rejected: RejectedOverride[];
16
+ }
17
+
18
+ // A selector semver cannot read (`workspace:`, a git url) says nothing about the
19
+ // version, so it is no reason to drop the override. `semver.satisfies` answers
20
+ // `false` rather than throwing on one, hence the explicit check.
21
+ const accepts = (version: string, range: string): boolean =>
22
+ semver.validRange(range) === null ||
23
+ semver.satisfies(version, range, { includePrerelease: true });
24
+
25
+ /**
26
+ * Split the planned overrides for a package manager whose overrides apply to
27
+ * every requester of the package, whatever range it declares (bun's
28
+ * `overrides`, pnpm's plain ones). Such an override cannot spare the dependents
29
+ * a conditional one would — pnpm's convergence overrides only repoint the edges
30
+ * that accept the version — so one a third-party range rejects would force that
31
+ * dependent onto a version it never allowed.
32
+ *
33
+ * The `converge` overrides are safe by construction: the detector only converges
34
+ * a member no third-party range holds elsewhere. A `reuse` override is derived
35
+ * from a single requester's range, and another requester can still reject it.
36
+ */
37
+ export const partitionUnconditionalOverrides = (
38
+ fixes: ClusterFix[],
39
+ overrides: PlannedOverride[],
40
+ ): PartitionedOverrides => {
41
+ const constraints = fixes.flatMap((fix) => fix.externalConstraints);
42
+ const safe: PlannedOverride[] = [];
43
+ const rejected: RejectedOverride[] = [];
44
+
45
+ for (const override of overrides) {
46
+ const rejectedBy = constraints.filter(
47
+ (constraint) =>
48
+ constraint.packageName === override.packageName &&
49
+ constraint.requesterName !== undefined &&
50
+ !accepts(override.version, constraint.range),
51
+ );
52
+ if (rejectedBy.length === 0) {
53
+ safe.push(override);
54
+ } else {
55
+ rejected.push({ override, rejectedBy });
56
+ }
57
+ }
58
+
59
+ return { safe, rejected };
60
+ };
@@ -0,0 +1,146 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual, strictEqual } from "node:assert/strict";
3
+ import {
4
+ buildVersionsSnapshot,
5
+ countDuplicatedPackages,
6
+ diffVersionsSnapshots,
7
+ } from "./versionsSnapshot.ts";
8
+ import type { SnapshotPackage, VersionsSnapshot } from "./versionsSnapshot.ts";
9
+
10
+ const npm = (name: string, version: string): SnapshotPackage => ({
11
+ type: "npm",
12
+ name,
13
+ version,
14
+ resolution: `${name}@${version}`,
15
+ });
16
+
17
+ const snapshot = (entries: Record<string, string[]>): VersionsSnapshot =>
18
+ new Map(Object.entries(entries));
19
+
20
+ describe("buildVersionsSnapshot", () => {
21
+ it("groups versions per package, sorted by semver", () => {
22
+ deepStrictEqual(
23
+ [
24
+ ...buildVersionsSnapshot([
25
+ npm("semver", "7.10.0"),
26
+ npm("semver", "7.9.0"),
27
+ npm("lodash", "4.17.21"),
28
+ ]),
29
+ ],
30
+ [
31
+ ["semver", ["7.9.0", "7.10.0"]],
32
+ ["lodash", ["4.17.21"]],
33
+ ],
34
+ );
35
+ });
36
+
37
+ it("counts one version per resolution, whatever it is installed at", () => {
38
+ deepStrictEqual(
39
+ buildVersionsSnapshot([npm("semver", "7.9.0"), npm("semver", "7.9.0")]),
40
+ snapshot({ semver: ["7.9.0"] }),
41
+ );
42
+ });
43
+
44
+ it("identifies a non-npm package by its resolution", () => {
45
+ deepStrictEqual(
46
+ buildVersionsSnapshot([
47
+ { type: "git", name: "psc", resolution: "psc@git:abcdef" },
48
+ ]),
49
+ snapshot({ psc: ["psc@git:abcdef"] }),
50
+ );
51
+ });
52
+ });
53
+
54
+ describe("diffVersionsSnapshots", () => {
55
+ it("keeps both sides whole, so the collapse reads as one", () => {
56
+ deepStrictEqual(
57
+ diffVersionsSnapshots(
58
+ snapshot({ "range-parser": ["1.2.1", "1.3.0"] }),
59
+ snapshot({ "range-parser": ["1.2.1"] }),
60
+ ),
61
+ [
62
+ {
63
+ packageName: "range-parser",
64
+ before: ["1.2.1", "1.3.0"],
65
+ after: ["1.2.1"],
66
+ },
67
+ ],
68
+ );
69
+ });
70
+
71
+ it("reports a package only partly collapsed with what is left", () => {
72
+ deepStrictEqual(
73
+ diffVersionsSnapshots(
74
+ snapshot({ semver: ["7.5.4", "7.6.0", "7.7.1"] }),
75
+ snapshot({ semver: ["7.6.0", "7.7.1"] }),
76
+ ),
77
+ [
78
+ {
79
+ packageName: "semver",
80
+ before: ["7.5.4", "7.6.0", "7.7.1"],
81
+ after: ["7.6.0", "7.7.1"],
82
+ },
83
+ ],
84
+ );
85
+ });
86
+
87
+ it("says nothing of a package whose versions did not move", () => {
88
+ deepStrictEqual(
89
+ diffVersionsSnapshots(
90
+ snapshot({ semver: ["7.7.1"] }),
91
+ snapshot({ semver: ["7.7.1"], lodash: ["4.17.21"] }),
92
+ ),
93
+ [],
94
+ );
95
+ });
96
+
97
+ // the cluster pass that made the edit reports it; no copy went away, so it is
98
+ // not a dedupe
99
+ it("says nothing of a version that moved without a copy going away", () => {
100
+ deepStrictEqual(
101
+ diffVersionsSnapshots(
102
+ snapshot({ metro: ["0.84.5"] }),
103
+ snapshot({ metro: ["0.87.0"] }),
104
+ ),
105
+ [],
106
+ );
107
+ });
108
+
109
+ // the rewritten copy drops the private subtrees of the versions it replaced,
110
+ // and the next install resolves them again: gone from the lockfile is not
111
+ // deduplicated
112
+ it("says nothing of a package that left the lockfile entirely", () => {
113
+ deepStrictEqual(
114
+ diffVersionsSnapshots(
115
+ snapshot({ "metro-config": ["0.84.5"] }),
116
+ snapshot({}),
117
+ ),
118
+ [],
119
+ );
120
+ });
121
+
122
+ it("sorts the entries by package name", () => {
123
+ deepStrictEqual(
124
+ diffVersionsSnapshots(
125
+ snapshot({ semver: ["1.0.0", "2.0.0"], lodash: ["3.0.0", "4.0.0"] }),
126
+ snapshot({ semver: ["2.0.0"], lodash: ["4.0.0"] }),
127
+ ).map((entry) => entry.packageName),
128
+ ["lodash", "semver"],
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("countDuplicatedPackages", () => {
134
+ it("counts the packages resolved more than once", () => {
135
+ strictEqual(
136
+ countDuplicatedPackages(
137
+ snapshot({
138
+ semver: ["7.5.4", "7.7.1"],
139
+ lodash: ["4.17.21"],
140
+ react: ["18.0.0", "19.0.0"],
141
+ }),
142
+ ),
143
+ 2,
144
+ );
145
+ });
146
+ });
@@ -0,0 +1,93 @@
1
+ import semver from "semver";
2
+
3
+ /**
4
+ * Every version a package resolves to in a lockfile, by package name.
5
+ *
6
+ * The duplicate snapshot only carries the resolutions of packages that have
7
+ * several, so it cannot name the version a merge landed on: once a package is
8
+ * deduplicated it leaves the snapshot altogether. Saying what a run deduped
9
+ * needs the whole picture, before and after.
10
+ */
11
+ export type VersionsSnapshot = Map<string, string[]>;
12
+
13
+ /**
14
+ * The shape every package manager's parsed package already has. `version` is
15
+ * only there for npm resolutions — a `git:` or `file:` copy is identified by its
16
+ * resolution instead, which is unique per install just the same.
17
+ */
18
+ export interface SnapshotPackage {
19
+ type: string;
20
+ name: string;
21
+ resolution: string;
22
+ version?: string;
23
+ }
24
+
25
+ const versionLabel = (pkg: SnapshotPackage): string =>
26
+ pkg.type === "npm" && pkg.version !== undefined && pkg.version !== ""
27
+ ? pkg.version
28
+ : pkg.resolution;
29
+
30
+ const compareVersions = (a: string, b: string): number =>
31
+ semver.valid(a) !== null && semver.valid(b) !== null
32
+ ? semver.compare(a, b)
33
+ : a.localeCompare(b);
34
+
35
+ export const buildVersionsSnapshot = (
36
+ packages: Iterable<SnapshotPackage>,
37
+ ): VersionsSnapshot => {
38
+ const snapshot: VersionsSnapshot = new Map();
39
+
40
+ for (const pkg of packages) {
41
+ const version = versionLabel(pkg);
42
+ const versions = snapshot.get(pkg.name);
43
+ if (versions === undefined) {
44
+ snapshot.set(pkg.name, [version]);
45
+ } else if (!versions.includes(version)) {
46
+ versions.push(version);
47
+ }
48
+ }
49
+
50
+ for (const versions of snapshot.values()) versions.sort(compareVersions);
51
+
52
+ return snapshot;
53
+ };
54
+
55
+ export interface DedupedPackage {
56
+ packageName: string;
57
+ // every version it resolved to before the run
58
+ before: string[];
59
+ // every version it resolves to now, fewer than before
60
+ after: string[];
61
+ }
62
+
63
+ /**
64
+ * What the two snapshots say a run collapsed, one entry per package: both sides
65
+ * whole, so a family converging on an older release reads as the two copies
66
+ * becoming one rather than as a downgrade.
67
+ *
68
+ * Only a package resolved fewer times than before is reported. A version that
69
+ * moved without a copy going away is a re-resolution, not a dedupe — the pass
70
+ * that made the edit already said so — and a package that left the lockfile
71
+ * entirely is neither: the in-memory rewrite drops the private subtrees of the
72
+ * versions it replaced, entries the next install resolves again.
73
+ */
74
+ export const diffVersionsSnapshots = (
75
+ before: VersionsSnapshot,
76
+ after: VersionsSnapshot,
77
+ ): DedupedPackage[] => {
78
+ const deduped: DedupedPackage[] = [];
79
+
80
+ for (const [packageName, versions] of before) {
81
+ const remaining = after.get(packageName);
82
+ if (remaining === undefined || remaining.length === 0) continue;
83
+ if (remaining.length >= versions.length) continue;
84
+
85
+ deduped.push({ packageName, before: versions, after: remaining });
86
+ }
87
+
88
+ return deduped.toSorted((a, b) => a.packageName.localeCompare(b.packageName));
89
+ };
90
+
91
+ // Packages the lockfile still resolves more than once.
92
+ export const countDuplicatedPackages = (snapshot: VersionsSnapshot): number =>
93
+ [...snapshot.values()].filter((versions) => versions.length > 1).length;