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,39 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { createCandidateVersionComparator } from "./compareCandidateVersions.ts";
3
+
4
+ const satisfiedCounts = new Map([
5
+ ["1.0.0", 1],
6
+ ["1.5.0", 3],
7
+ ["2.0.0", 3],
8
+ ["3.0.0", 2],
9
+ ]);
10
+ const satisfiedCountOf = (version: string): number =>
11
+ satisfiedCounts.get(version) ?? 0;
12
+
13
+ describe("createCandidateVersionComparator", () => {
14
+ it("puts the version satisfying the most dependents first", () => {
15
+ const compare = createCandidateVersionComparator({ satisfiedCountOf });
16
+
17
+ expect(["1.0.0", "3.0.0", "1.5.0"].toSorted(compare)).toEqual([
18
+ "1.5.0",
19
+ "3.0.0",
20
+ "1.0.0",
21
+ ]);
22
+ });
23
+
24
+ it("breaks a tie on the highest version", () => {
25
+ const compare = createCandidateVersionComparator({ satisfiedCountOf });
26
+
27
+ expect(["1.5.0", "2.0.0"].toSorted(compare)).toEqual(["2.0.0", "1.5.0"]);
28
+ });
29
+
30
+ it("ranks on semver alone without a satisfied count", () => {
31
+ const compare = createCandidateVersionComparator();
32
+
33
+ expect(["1.0.0", "3.0.0", "1.5.0"].toSorted(compare)).toEqual([
34
+ "3.0.0",
35
+ "1.5.0",
36
+ "1.0.0",
37
+ ]);
38
+ });
39
+ });
@@ -0,0 +1,23 @@
1
+ import semver from "semver";
2
+
3
+ export interface CandidateVersionComparatorOptions {
4
+ // omit to rank on semver alone
5
+ satisfiedCountOf?: (version: string) => number;
6
+ }
7
+
8
+ /**
9
+ * A version satisfying every dependent holds the maximal count, so it always
10
+ * sorts first: "the highest version that satisfies everyone" falls out of this
11
+ * ordering instead of needing a pass of its own.
12
+ */
13
+ export const createCandidateVersionComparator =
14
+ ({ satisfiedCountOf }: CandidateVersionComparatorOptions = {}) =>
15
+ (versionA: string, versionB: string): number => {
16
+ if (satisfiedCountOf) {
17
+ const satisfiesA = satisfiedCountOf(versionA);
18
+ const satisfiesB = satisfiedCountOf(versionB);
19
+ if (satisfiesB > satisfiesA) return 1;
20
+ if (satisfiesB < satisfiesA) return -1;
21
+ }
22
+ return semver.rcompare(versionA, versionB);
23
+ };
@@ -0,0 +1,156 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual, strictEqual } from "node:assert/strict";
3
+ import {
4
+ createPackageFilter,
5
+ describeSkippedClusterFix,
6
+ selectClusterFixes,
7
+ } from "./createPackageFilter.ts";
8
+ import type { ClusterFix } from "./identifyLockstepClusterFixes.ts";
9
+
10
+ const clusterFix = (members: string[]): ClusterFix => ({
11
+ members,
12
+ duplicatedMembers: members,
13
+ memberVersions: {},
14
+ target: null,
15
+ direction: "none",
16
+ convergentMembers: [],
17
+ driverMembers: [],
18
+ excludedMembers: [],
19
+ anchor: null,
20
+ reuseFixes: [],
21
+ floatingMembers: [],
22
+ workspaceChanges: [],
23
+ reResolutionSet: [],
24
+ externalConstraints: [],
25
+ needsRoundTrip: false,
26
+ applicable: true,
27
+ });
28
+
29
+ describe("createPackageFilter", () => {
30
+ it("selects everything when nothing is asked for", () => {
31
+ const filter = createPackageFilter();
32
+
33
+ strictEqual(filter.selectsEverything, true);
34
+ strictEqual(filter.selects("lodash"), true);
35
+ strictEqual(filter.selects("@babel/core"), true);
36
+ strictEqual(filter.rejectionReason("lodash"), undefined);
37
+ });
38
+
39
+ it("keeps only the included names", () => {
40
+ const filter = createPackageFilter({ include: ["lodash", "@babel/core"] });
41
+
42
+ strictEqual(filter.selectsEverything, false);
43
+ strictEqual(filter.selects("lodash"), true);
44
+ strictEqual(filter.selects("@babel/core"), true);
45
+ strictEqual(filter.selects("react"), false);
46
+ strictEqual(filter.rejectionReason("react"), "not selected");
47
+ });
48
+
49
+ it("drops the excluded names and keeps the rest", () => {
50
+ const filter = createPackageFilter({ exclude: ["lodash"] });
51
+
52
+ strictEqual(filter.selects("react"), true);
53
+ strictEqual(filter.selects("lodash"), false);
54
+ strictEqual(filter.rejectionReason("lodash"), "excluded");
55
+ });
56
+
57
+ it("lets an exclusion hold back part of an included scope", () => {
58
+ const filter = createPackageFilter({
59
+ includeScopes: ["@babel"],
60
+ exclude: ["@babel/runtime"],
61
+ });
62
+
63
+ strictEqual(filter.selects("@babel/core"), true);
64
+ strictEqual(filter.selects("@babel/runtime"), false);
65
+ strictEqual(filter.rejectionReason("@babel/runtime"), "excluded");
66
+ });
67
+
68
+ it("reads a scope with or without its leading @", () => {
69
+ const withAt = createPackageFilter({ includeScopes: ["@babel"] });
70
+ const withoutAt = createPackageFilter({ includeScopes: ["babel"] });
71
+
72
+ for (const filter of [withAt, withoutAt]) {
73
+ strictEqual(filter.selects("@babel/core"), true);
74
+ strictEqual(filter.selects("@babel/plugin-transform-runtime"), true);
75
+ strictEqual(filter.selects("babel-jest"), false);
76
+ strictEqual(filter.selects("@babel-other/core"), false);
77
+ }
78
+ });
79
+
80
+ it("excludes a whole scope", () => {
81
+ const filter = createPackageFilter({ excludeScopes: ["@types"] });
82
+
83
+ strictEqual(filter.selects("@types/node"), false);
84
+ strictEqual(filter.selects("typescript"), true);
85
+ });
86
+
87
+ it("matches a name holding glob-adjacent characters literally", () => {
88
+ const filter = createPackageFilter({
89
+ include: ["lodash.merge", "@scope/a-b_c"],
90
+ });
91
+
92
+ strictEqual(filter.selects("lodash.merge"), true);
93
+ strictEqual(filter.selects("@scope/a-b_c"), true);
94
+ strictEqual(filter.selects("lodashXmerge"), false);
95
+ });
96
+
97
+ it("stops a single star at the scope separator", () => {
98
+ const filter = createPackageFilter({ include: ["@babel/*"] });
99
+
100
+ strictEqual(filter.selects("@babel/core"), true);
101
+ strictEqual(filter.selects("babel"), false);
102
+
103
+ const everyUnscoped = createPackageFilter({ include: ["*"] });
104
+ strictEqual(everyUnscoped.selects("lodash"), true);
105
+ strictEqual(everyUnscoped.selects("@babel/core"), false);
106
+ });
107
+ });
108
+
109
+ describe("selectClusterFixes", () => {
110
+ it("passes every fix through when the filter selects everything", () => {
111
+ const fixes = [clusterFix(["@babel/core", "@babel/types"])];
112
+
113
+ deepStrictEqual(selectClusterFixes(fixes, createPackageFilter()), {
114
+ selected: fixes,
115
+ skipped: [],
116
+ });
117
+ });
118
+
119
+ it("keeps a fix whose whole family is selected", () => {
120
+ const fix = clusterFix(["@babel/core", "@babel/types"]);
121
+
122
+ const { selected, skipped } = selectClusterFixes(
123
+ [fix],
124
+ createPackageFilter({ includeScopes: ["@babel"] }),
125
+ );
126
+
127
+ deepStrictEqual(selected, [fix]);
128
+ deepStrictEqual(skipped, []);
129
+ });
130
+
131
+ it("skips a partly selected family and names what blocked it", () => {
132
+ const fix = clusterFix(["@babel/core", "@babel/types"]);
133
+
134
+ const { selected, skipped } = selectClusterFixes(
135
+ [fix],
136
+ createPackageFilter({ include: ["@babel/types"] }),
137
+ );
138
+
139
+ deepStrictEqual(selected, []);
140
+ deepStrictEqual(skipped, [{ fix, blockedBy: ["@babel/core"] }]);
141
+ });
142
+ });
143
+
144
+ describe("describeSkippedClusterFix", () => {
145
+ it("names the family and counts the blockers past the first few", () => {
146
+ const members = ["a", "b", "c", "d", "e"];
147
+
148
+ strictEqual(
149
+ describeSkippedClusterFix({
150
+ fix: clusterFix(members),
151
+ blockedBy: members,
152
+ }),
153
+ "cluster a (+4 more): a, b, c (+2 more) not selected by the filter, and a family only converges as a whole",
154
+ );
155
+ });
156
+ });
@@ -0,0 +1,142 @@
1
+ import picomatch from "picomatch";
2
+ import { clusterLabel } from "./clusterLabel.ts";
3
+ import type { ClusterFix } from "./identifyLockstepClusterFixes.ts";
4
+
5
+ export interface PackageFilterOptions {
6
+ // glob patterns matched against the full npm name, `@scope/name` included
7
+ include?: string[];
8
+ exclude?: string[];
9
+ // bare scope names, with or without the leading `@`
10
+ includeScopes?: string[];
11
+ excludeScopes?: string[];
12
+ }
13
+
14
+ export interface PackageFilter {
15
+ // nothing was asked for, so every package is selected and callers holding a
16
+ // whole lockfile can skip the pass entirely
17
+ selectsEverything: boolean;
18
+ selects: (packageName: string) => boolean;
19
+ // why a package is left alone, for reports; undefined when it is selected
20
+ rejectionReason: (packageName: string) => string | undefined;
21
+ }
22
+
23
+ const scopePattern = (scope: string): string =>
24
+ `${scope.startsWith("@") ? scope : `@${scope}`}/**`;
25
+
26
+ const toPatterns = (names: string[], scopes: string[]): string[] => [
27
+ ...names,
28
+ ...scopes.map(scopePattern),
29
+ ];
30
+
31
+ // picomatch throws on an empty pattern list, and "no pattern" has to mean "no
32
+ // opinion" rather than "matches nothing".
33
+ const compile = (patterns: string[]): ((name: string) => boolean) =>
34
+ patterns.length === 0 ? () => false : picomatch(patterns);
35
+
36
+ /**
37
+ * Selects the packages a dedupe run is allowed to touch. Deduplicating a large
38
+ * lockfile in one pass produces a diff no one reviews, so every tool exposes
39
+ * this as the way to work through it a family at a time.
40
+ *
41
+ * A package is selected when the include list is empty or one of its patterns
42
+ * matches, and no exclude pattern matches — exclusion always wins, so a scope
43
+ * can be selected with a few of its packages held back.
44
+ *
45
+ * Patterns are globs with path semantics (`*` stops at `/`), the same as the
46
+ * `why-duplicate` filters: `@babel/*` matches `@babel/core`, a bare `*` does
47
+ * not. npm names carry no glob metacharacter, so a plain name is still an
48
+ * exact match.
49
+ */
50
+ export const createPackageFilter = ({
51
+ include = [],
52
+ exclude = [],
53
+ includeScopes = [],
54
+ excludeScopes = [],
55
+ }: PackageFilterOptions = {}): PackageFilter => {
56
+ const includePatterns = toPatterns(include, includeScopes);
57
+ const excludePatterns = toPatterns(exclude, excludeScopes);
58
+ const isIncluded = compile(includePatterns);
59
+ const isExcluded = compile(excludePatterns);
60
+
61
+ const rejectionReason = (packageName: string): string | undefined => {
62
+ if (isExcluded(packageName)) return "excluded";
63
+ if (includePatterns.length > 0 && !isIncluded(packageName)) {
64
+ return "not selected";
65
+ }
66
+ return undefined;
67
+ };
68
+
69
+ return {
70
+ selectsEverything:
71
+ includePatterns.length === 0 && excludePatterns.length === 0,
72
+ selects: (packageName) => rejectionReason(packageName) === undefined,
73
+ rejectionReason,
74
+ };
75
+ };
76
+
77
+ // A map keyed by package name is where the filter applies exactly: an unselected
78
+ // package keeps every resolution it has, rather than being partly rewritten.
79
+ export const selectPackages = <T>(
80
+ packages: Record<string, T>,
81
+ filter: PackageFilter,
82
+ ): Record<string, T> =>
83
+ filter.selectsEverything
84
+ ? packages
85
+ : Object.fromEntries(
86
+ Object.entries(packages).filter(([packageName]) =>
87
+ filter.selects(packageName),
88
+ ),
89
+ );
90
+
91
+ export interface SkippedClusterFix {
92
+ fix: ClusterFix;
93
+ // the members that are not selected, so a fix doing nothing is never silent
94
+ blockedBy: string[];
95
+ }
96
+
97
+ export interface SelectedClusterFixes {
98
+ selected: ClusterFix[];
99
+ skipped: SkippedClusterFix[];
100
+ }
101
+
102
+ /**
103
+ * A cluster fix moves its whole family in lockstep, so it is kept only when
104
+ * every member is selected: applying it for part of a family would move the
105
+ * rest anyway, past the filter the user set. Select a family with a scope, or
106
+ * by naming all of its members.
107
+ */
108
+ export const selectClusterFixes = (
109
+ fixes: ClusterFix[],
110
+ filter: PackageFilter,
111
+ ): SelectedClusterFixes => {
112
+ if (filter.selectsEverything) return { selected: fixes, skipped: [] };
113
+
114
+ const selected: ClusterFix[] = [];
115
+ const skipped: SkippedClusterFix[] = [];
116
+
117
+ for (const fix of fixes) {
118
+ const blockedBy = fix.members.filter((member) => !filter.selects(member));
119
+ if (blockedBy.length === 0) {
120
+ selected.push(fix);
121
+ } else {
122
+ skipped.push({ fix, blockedBy });
123
+ }
124
+ }
125
+
126
+ return { selected, skipped };
127
+ };
128
+
129
+ // A family can block on a dozen members, and naming them all buries the point.
130
+ const namedBlockers = 3;
131
+
132
+ // Phrased for the `skipped` list of an apply plan, so both tools explain a
133
+ // filtered-out family the same way.
134
+ export const describeSkippedClusterFix = ({
135
+ fix,
136
+ blockedBy,
137
+ }: SkippedClusterFix): string => {
138
+ const named = blockedBy.slice(0, namedBlockers).join(", ");
139
+ const rest = blockedBy.length - namedBlockers;
140
+ const blockers = rest > 0 ? `${named} (+${rest} more)` : named;
141
+ return `cluster ${clusterLabel(fix.members)}: ${blockers} not selected by the filter, and a family only converges as a whole`;
142
+ };
@@ -0,0 +1,24 @@
1
+ // Every resolution that is one copy too many, as `name@version` ids. Sets, not
2
+ // counts: a change that removes one duplicate and introduces another is not an
3
+ // improvement, and a count would hide it.
4
+ export type DuplicateSnapshot = Set<string>;
5
+
6
+ export interface DuplicateDiff {
7
+ removed: string[];
8
+ added: string[];
9
+ improved: boolean;
10
+ }
11
+
12
+ export const diffDuplicates = (
13
+ before: DuplicateSnapshot,
14
+ after: DuplicateSnapshot,
15
+ ): DuplicateDiff => {
16
+ const removed = [...before].filter((resolution) => !after.has(resolution));
17
+ const added = [...after].filter((resolution) => !before.has(resolution));
18
+
19
+ return {
20
+ removed: removed.toSorted((a, b) => a.localeCompare(b)),
21
+ added: added.toSorted((a, b) => a.localeCompare(b)),
22
+ improved: added.length === 0 && removed.length > 0,
23
+ };
24
+ };
@@ -0,0 +1,28 @@
1
+ import { readFileSync, rmSync, writeFileSync } from "node:fs";
2
+
3
+ export interface FileSnapshot {
4
+ path: string;
5
+ // undefined when the file did not exist: restoring deletes it again
6
+ content: string | undefined;
7
+ }
8
+
9
+ const readIfExists = (path: string): string | undefined => {
10
+ try {
11
+ return readFileSync(path, "utf8");
12
+ } catch {
13
+ return undefined;
14
+ }
15
+ };
16
+
17
+ export const captureFiles = (paths: string[]): FileSnapshot[] =>
18
+ paths.map((path) => ({ path, content: readIfExists(path) }));
19
+
20
+ export const restoreFiles = (snapshots: FileSnapshot[]): void => {
21
+ for (const { path, content } of snapshots) {
22
+ if (content === undefined) {
23
+ rmSync(path, { force: true });
24
+ } else {
25
+ writeFileSync(path, content);
26
+ }
27
+ }
28
+ };
@@ -0,0 +1,66 @@
1
+ import { afterEach, describe, it } from "bun:test";
2
+ import { strictEqual } from "node:assert/strict";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { findProjectRoot } from "./findProjectRoot.ts";
7
+
8
+ const lockfileName = "pm-tools.lock";
9
+
10
+ const dirs: string[] = [];
11
+
12
+ afterEach(() => {
13
+ for (const dir of dirs.splice(0)) {
14
+ rmSync(dir, { recursive: true, force: true });
15
+ }
16
+ });
17
+
18
+ const tempRoot = (): string => {
19
+ const dir = mkdtempSync(join(tmpdir(), `find-project-root-${process.pid}-`));
20
+ dirs.push(dir);
21
+ return dir;
22
+ };
23
+
24
+ const makeDir = (root: string, relativePath: string): string => {
25
+ const dir = join(root, relativePath);
26
+ mkdirSync(dir, { recursive: true });
27
+ return dir;
28
+ };
29
+
30
+ const makeLockfile = (dir: string): void => {
31
+ writeFileSync(join(dir, lockfileName), "");
32
+ };
33
+
34
+ describe("findProjectRoot", () => {
35
+ it("finds the lockfile in cwd", () => {
36
+ const root = tempRoot();
37
+ makeLockfile(root);
38
+
39
+ strictEqual(findProjectRoot({ lockfileName, cwd: root }), root);
40
+ });
41
+
42
+ it("walks up to an ancestor holding the lockfile", () => {
43
+ const root = tempRoot();
44
+ makeLockfile(root);
45
+ const nested = makeDir(root, join("packages", "app", "src"));
46
+
47
+ strictEqual(findProjectRoot({ lockfileName, cwd: nested }), root);
48
+ });
49
+
50
+ it("stops at the nearest one when two ancestors hold a lockfile", () => {
51
+ const root = tempRoot();
52
+ makeLockfile(root);
53
+ const nestedProject = makeDir(root, "examples/embedded");
54
+ makeLockfile(nestedProject);
55
+ const nested = makeDir(nestedProject, "src");
56
+
57
+ strictEqual(findProjectRoot({ lockfileName, cwd: nested }), nestedProject);
58
+ });
59
+
60
+ it("returns null when no ancestor up to the filesystem root has one", () => {
61
+ const root = tempRoot();
62
+ const nested = makeDir(root, "packages/app");
63
+
64
+ strictEqual(findProjectRoot({ lockfileName, cwd: nested }), null);
65
+ });
66
+ });
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { createColorize, shouldColorize } from "./reportColors.ts";
4
+
5
+ export interface ProjectRootOptions {
6
+ lockfileName: string;
7
+ cwd?: string;
8
+ }
9
+
10
+ /**
11
+ * The nearest directory holding the lockfile, starting at `cwd` and walking up
12
+ * to the filesystem root; null when there is none.
13
+ *
14
+ * Nearest wins, which is the rule every package manager applies: a nested
15
+ * project carrying its own lockfile keeps resolving to itself rather than to the
16
+ * workspace above it.
17
+ */
18
+ export const findProjectRoot = ({
19
+ lockfileName,
20
+ cwd = process.cwd(),
21
+ }: ProjectRootOptions): string | null => {
22
+ let dir = cwd;
23
+ for (;;) {
24
+ if (existsSync(join(dir, lockfileName))) return dir;
25
+ const parent = dirname(dir);
26
+ if (parent === dir) return null;
27
+ dir = parent;
28
+ }
29
+ };
30
+
31
+ /**
32
+ * `findProjectRoot` for a bin: the project root the run operates on, whatever
33
+ * subdirectory it was invoked from.
34
+ *
35
+ * Returns null when there is no project to work on — as `parseBinArgs` does, the
36
+ * reason is printed and the exit code already says so, leaving the caller
37
+ * nothing to do but return.
38
+ *
39
+ * The notice goes to stderr, and only when the root is not `cwd` — a run from
40
+ * the root prints exactly what it always did, and a redirected report stays
41
+ * parseable.
42
+ */
43
+ export const resolveProjectDir = ({
44
+ lockfileName,
45
+ cwd = process.cwd(),
46
+ }: ProjectRootOptions): string | null => {
47
+ const projectDir = findProjectRoot({ lockfileName, cwd });
48
+
49
+ if (projectDir === null) {
50
+ console.error(`No ${lockfileName} found in ${cwd} or any parent directory`);
51
+ process.exitCode = 1;
52
+ return null;
53
+ }
54
+
55
+ if (projectDir !== cwd) {
56
+ const colorize = createColorize(shouldColorize(process.stderr));
57
+ console.error(colorize("dim", `using ${join(projectDir, lockfileName)}`));
58
+ }
59
+
60
+ return projectDir;
61
+ };