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,602 @@
1
+ import semver from "semver";
2
+
3
+ // Where a workspace requester declares the range, for appliers that have to
4
+ // edit it back. Only set for importer (workspace) dependents.
5
+ export interface ClusterWorkspaceRef {
6
+ path: string;
7
+ depType: string;
8
+ }
9
+
10
+ export interface ClusterExternalConstraint {
11
+ // display key of the requester
12
+ requester: string;
13
+ // npm name of the requester, or undefined for a workspace/importer requester
14
+ requesterName: string | undefined;
15
+ packageName: string;
16
+ range: string;
17
+ // the requester declares the package under a key naming another package, which
18
+ // some package managers' overrides cannot repoint
19
+ isAlias?: boolean;
20
+ // version this requester actually got, when the lockfile records it
21
+ resolvedVersion?: string;
22
+ workspace?: ClusterWorkspaceRef;
23
+ }
24
+
25
+ // A workspace pin that has to be edited for the cluster to converge.
26
+ export interface ClusterWorkspaceChange extends ClusterExternalConstraint {
27
+ to: string;
28
+ }
29
+
30
+ // A member kept out of the convergence by one of its own third-party
31
+ // constraints; its duplicate survives the fix.
32
+ export interface ClusterExcludedMember {
33
+ packageName: string;
34
+ blockedBy: ClusterExternalConstraint[];
35
+ }
36
+
37
+ // One cluster member, reduced to what target selection needs.
38
+ export interface ClusterMember {
39
+ npmVersions: string[];
40
+ resolutionCount: number;
41
+ }
42
+
43
+ // name -> installed state. Package-manager packages adapt their own lockfile
44
+ // model into this neutral shape.
45
+ export type ClusterMembersMap = Record<string, ClusterMember>;
46
+
47
+ export interface ClusterDependent {
48
+ requester: string;
49
+ requesterName: string | undefined;
50
+ range: string;
51
+ // see `ClusterExternalConstraint.isAlias`
52
+ isAlias?: boolean;
53
+ workspace?: ClusterWorkspaceRef;
54
+ // version this requester actually got, when the lockfile records it
55
+ resolvedVersion?: string;
56
+ // see `ResolutionDependent.nonSemver`: reported, never weighed
57
+ nonSemver?: boolean;
58
+ }
59
+
60
+ export type ClusterDependentsMap = Map<string, ClusterDependent[]>;
61
+
62
+ // An open range that resolved away from the version the workspace pins the
63
+ // family at, although the range accepts it. Nothing to collapse — both copies
64
+ // have other dependents — but the requester is using a different copy than the
65
+ // workspace does, which for a plugin of the pinned package is a mismatch.
66
+ export interface ClusterReuseFix {
67
+ requester: string;
68
+ requesterName: string;
69
+ packageName: string;
70
+ range: string;
71
+ from: string;
72
+ to: string;
73
+ }
74
+
75
+ // What a member currently has installed. `nonNpmCount` is how many of its
76
+ // resolutions are not npm ones (git, file, …), which carry no comparable
77
+ // version but still exist in the tree.
78
+ export interface ClusterMemberVersions {
79
+ versions: string[];
80
+ nonNpmCount: number;
81
+ }
82
+
83
+ export interface ClusterFix {
84
+ members: string[];
85
+ duplicatedMembers: string[];
86
+ // installed versions per member, for display
87
+ memberVersions: Record<string, ClusterMemberVersions>;
88
+ // null when no candidate version lets any duplicated member converge
89
+ target: string | null;
90
+ // how the members that move reach the target; "same" when nothing moves
91
+ direction: "down" | "none" | "same" | "up";
92
+ // duplicated members that do converge on the target
93
+ convergentMembers: string[];
94
+ // the convergent members a real constraint applies to. The rest are dragged
95
+ // along by an open range (`*`) or by a sibling's pin: they carry no decision
96
+ // of their own and follow whatever these resolve to
97
+ driverMembers: string[];
98
+ // members whose own third-party constraints reject the target: they keep
99
+ // their resolutions, and their duplicates with them
100
+ excludedMembers: ClusterExcludedMember[];
101
+ // exact version a workspace pin fixes the family at, if any. It anchors
102
+ // `reuseFixes`; it does not make the pin immutable — see `workspaceChanges`
103
+ anchor: string | null;
104
+ // open ranges that could have reused the anchored version and did not
105
+ reuseFixes: ClusterReuseFix[];
106
+ // members the package manager has to re-resolve, at a version only it can
107
+ // pick: nothing pins them, so the result has to be verified after installing
108
+ floatingMembers: string[];
109
+ // workspace ranges to edit before installing, exact pins included: a pin is a
110
+ // decision, so it is a last resort (see `betterCandidate`), not immutable. A
111
+ // declaration made through an alias key is the exception — it binds
112
+ workspaceChanges: ClusterWorkspaceChange[];
113
+ // members that must be re-resolved through an install round-trip because they
114
+ // carry an external dependent and the target version is not installed
115
+ reResolutionSet: string[];
116
+ externalConstraints: ClusterExternalConstraint[];
117
+ // true when the fix cannot be applied by a lockfile rewrite alone
118
+ needsRoundTrip: boolean;
119
+ // false when no duplicated member can converge. `reuseFixes` is independent:
120
+ // a cluster can be unfixable as a whole and still have open ranges to repoint
121
+ applicable: boolean;
122
+ }
123
+
124
+ const accepts = (version: string, range: string): boolean =>
125
+ semver.satisfies(version, range, { includePrerelease: true });
126
+
127
+ interface Candidate {
128
+ version: string;
129
+ // duplicated members that collapse onto this version
130
+ convergentMembers: string[];
131
+ // duplicated members that do not, with the third-party ranges to blame
132
+ excluded: Map<string, ClusterExternalConstraint[]>;
133
+ // members the resolver has to move, version unknown
134
+ floatingMembers: string[];
135
+ // workspace pins that would have to be edited to reach this version
136
+ pinsToEdit: ClusterExternalConstraint[];
137
+ // members this version is installed for, among those free to move
138
+ installedCount: number;
139
+ }
140
+
141
+ // Prefer a candidate that needs no edit to the user's own package.json — a pin
142
+ // is a decision, so changing it is a last resort, taken only when nothing else
143
+ // deduplicates anything. Then the candidate that deduplicates the most members,
144
+ // then the one already installed for the most of them (a version the lockfile
145
+ // never mentions for a member is a far weaker proposal), then the highest.
146
+ const betterCandidate = (a: Candidate, b: Candidate): Candidate => {
147
+ const aRespectsPins =
148
+ a.pinsToEdit.length === 0 && a.convergentMembers.length > 0;
149
+ const bRespectsPins =
150
+ b.pinsToEdit.length === 0 && b.convergentMembers.length > 0;
151
+ if (aRespectsPins !== bRespectsPins) {
152
+ return aRespectsPins ? a : b;
153
+ }
154
+ if (a.convergentMembers.length !== b.convergentMembers.length) {
155
+ return a.convergentMembers.length > b.convergentMembers.length ? a : b;
156
+ }
157
+ if (a.installedCount !== b.installedCount) {
158
+ return a.installedCount > b.installedCount ? a : b;
159
+ }
160
+ return semver.gt(a.version, b.version) ? a : b;
161
+ };
162
+
163
+ const directionOf = (
164
+ replacedVersions: string[],
165
+ target: string,
166
+ ): ClusterFix["direction"] => {
167
+ if (replacedVersions.length === 0) return "same";
168
+ return replacedVersions.some((version) => semver.gt(version, target))
169
+ ? "down"
170
+ : "up";
171
+ };
172
+
173
+ /**
174
+ * Compute dedupe opportunities for lockstep clusters (see
175
+ * `buildLockstepClusters`) that a per-package pass cannot: a family of
176
+ * co-versioned packages whose duplicate is kept alive only because a few
177
+ * externally-requested members resolve high. The fix converges the family onto
178
+ * a single version; fetching absent manifests and cascading the internal pins
179
+ * is left to the package manager, surfaced here as `reResolutionSet` /
180
+ * `needsRoundTrip`.
181
+ *
182
+ * Two kinds of external constraint are treated differently:
183
+ * - a third-party requester's range is immutable, so a member it rejects the
184
+ * target for is excluded from the convergence instead of sinking the whole
185
+ * cluster (clusters routinely span several publishers, and one unfixable
186
+ * member should not hide the rest);
187
+ * - a workspace pin belongs to the user, so it is reported as a
188
+ * `workspaceChanges` edit rather than a blocker.
189
+ *
190
+ * Ranges in `dependents` must be the ranges the requesters declare. A package
191
+ * manager that only stores resolved versions in its lockfile has to recover
192
+ * them (pnpm reads the installed manifests) or the constraints read as exact
193
+ * pins and no target is found.
194
+ */
195
+ export const identifyLockstepClusterFixes = (
196
+ clusters: string[][],
197
+ members: ClusterMembersMap,
198
+ dependents: ClusterDependentsMap,
199
+ ): ClusterFix[] => {
200
+ const fixes: ClusterFix[] = [];
201
+
202
+ for (const clusterMembers of clusters) {
203
+ const memberSet = new Set(clusterMembers);
204
+ const npmVersionsOf = (member: string): string[] =>
205
+ members[member]?.npmVersions ?? [];
206
+
207
+ const duplicatedMembers = clusterMembers.filter(
208
+ (member) => (members[member]?.resolutionCount ?? 0) > 1,
209
+ );
210
+ // a cluster with no duplicated member has nothing to dedupe
211
+ if (duplicatedMembers.length === 0) continue;
212
+
213
+ const memberVersions = Object.fromEntries(
214
+ clusterMembers.map((member) => {
215
+ const versions = npmVersionsOf(member);
216
+ return [
217
+ member,
218
+ {
219
+ versions,
220
+ nonNpmCount: Math.max(
221
+ (members[member]?.resolutionCount ?? 0) - versions.length,
222
+ 0,
223
+ ),
224
+ },
225
+ ];
226
+ }),
227
+ );
228
+
229
+ const externalConstraints: ClusterExternalConstraint[] = [];
230
+ // ranges that can rule a version out: a third-party one, and a workspace one
231
+ // declared through an alias key. A workspace range under the package's own
232
+ // name belongs to the user, so it never blocks — it is ranked against
233
+ // instead (`pinsToEdit`), and proposed for editing only when nothing else
234
+ // deduplicates anything.
235
+ const bindingByMember = new Map<string, ClusterExternalConstraint[]>();
236
+ const internalByMember = new Map<
237
+ string,
238
+ { requesterName: string; range: string }[]
239
+ >();
240
+ const workspaceConstraints: ClusterExternalConstraint[] = [];
241
+ const exactWorkspacePins: ClusterExternalConstraint[] = [];
242
+ const membersWithExternalDependent = new Set<string>();
243
+
244
+ for (const member of clusterMembers) {
245
+ for (const dependent of dependents.get(member) ?? []) {
246
+ // a range semver cannot read rules no version in or out; counted as an
247
+ // external constraint it would block every convergence it touches
248
+ if (dependent.nonSemver) continue;
249
+
250
+ const isInternal =
251
+ dependent.requesterName !== undefined &&
252
+ memberSet.has(dependent.requesterName);
253
+ if (isInternal) {
254
+ const forMember = internalByMember.get(member) ?? [];
255
+ forMember.push({
256
+ requesterName: dependent.requesterName!,
257
+ range: dependent.range,
258
+ });
259
+ internalByMember.set(member, forMember);
260
+ continue;
261
+ }
262
+
263
+ const constraint: ClusterExternalConstraint = {
264
+ requester: dependent.requester,
265
+ requesterName: dependent.requesterName,
266
+ packageName: member,
267
+ range: dependent.range,
268
+ isAlias: dependent.isAlias,
269
+ resolvedVersion: dependent.resolvedVersion,
270
+ workspace: dependent.workspace,
271
+ };
272
+ externalConstraints.push(constraint);
273
+ membersWithExternalDependent.add(member);
274
+
275
+ // An exact workspace pin is as binding as a third-party range: the user
276
+ // chose that version, so the family has to live with it.
277
+ //
278
+ // A workspace declaration made through an alias key is binding outright:
279
+ // `"@typescript/native": "npm:typescript@7.0.2"` asks for a copy under a
280
+ // name of its own, beside whatever the `typescript` key resolves to.
281
+ // Moving its version is not a dedupe of the family, it is the undoing of
282
+ // the alias — so the range blocks the candidate instead of being
283
+ // proposed for editing. It only ever blocks a version the alias range
284
+ // already rejects, which is exactly the case where the edit would
285
+ // repoint the alias at another package version.
286
+ if (
287
+ constraint.requesterName === undefined &&
288
+ constraint.isAlias !== true
289
+ ) {
290
+ workspaceConstraints.push(constraint);
291
+ if (semver.valid(constraint.range) !== null) {
292
+ exactWorkspacePins.push(constraint);
293
+ }
294
+ } else {
295
+ const forMember = bindingByMember.get(member);
296
+ if (forMember) {
297
+ forMember.push(constraint);
298
+ } else {
299
+ bindingByMember.set(member, [constraint]);
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ const candidateVersions = [
306
+ ...new Set(clusterMembers.flatMap(npmVersionsOf)),
307
+ ];
308
+
309
+ // A binding range decides whether a member is free to move at all — for a
310
+ // non-duplicated member that means being re-resolved along with the family.
311
+ const rejectingConstraints = (
312
+ member: string,
313
+ version: string,
314
+ ): ClusterExternalConstraint[] =>
315
+ (bindingByMember.get(member) ?? []).filter(
316
+ (constraint) => !accepts(version, constraint.range),
317
+ );
318
+
319
+ const canMove = (member: string, version: string): boolean =>
320
+ rejectingConstraints(member, version).length === 0;
321
+
322
+ const isInstalled = (member: string, version: string): boolean =>
323
+ npmVersionsOf(member).some((installed) => semver.eq(installed, version));
324
+
325
+ // A range that accepts every version the member has installed expresses no
326
+ // preference: it cannot be the reason two copies exist, and it follows
327
+ // whatever the family resolves to.
328
+ const isOpenRange = (member: string, range: string): boolean =>
329
+ npmVersionsOf(member).every((version) => accepts(version, range));
330
+
331
+ const hasRealConstraint = (member: string): boolean =>
332
+ [
333
+ ...(bindingByMember.get(member) ?? []),
334
+ ...workspaceConstraints.filter(
335
+ (constraint) => constraint.packageName === member,
336
+ ),
337
+ ].some((constraint) => !isOpenRange(member, constraint.range));
338
+
339
+ const externalRangesOn = (member: string): string[] =>
340
+ [
341
+ ...(bindingByMember.get(member) ?? []),
342
+ ...workspaceConstraints.filter(
343
+ (constraint) => constraint.packageName === member,
344
+ ),
345
+ ].map((constraint) => constraint.range);
346
+
347
+ const rangeFloor = (range: string): string | null => {
348
+ try {
349
+ return semver.minVersion(range)?.version ?? null;
350
+ } catch {
351
+ return null;
352
+ }
353
+ };
354
+
355
+ // Re-resolving a member *to a named version* absent from the lock only makes
356
+ // sense when a dependent asks for that version line: `^8.59.1` is evidence
357
+ // that 8.59.1 exists, `*` and `0.83 - 0.86` are not. Without this, any
358
+ // externally-requested member looks free to move anywhere its ranges happen
359
+ // to admit — which is how a react-native@0.87.0 pulled into a metro cluster
360
+ // ends up "converging" on a metro version number.
361
+ const canReResolve = (member: string, version: string): boolean =>
362
+ isInstalled(member, version) ||
363
+ !membersWithExternalDependent.has(member) ||
364
+ externalRangesOn(member).some((range) => {
365
+ const floor = rangeFloor(range);
366
+ return floor !== null && semver.eq(floor, version);
367
+ });
368
+
369
+ // Whether a member could hold a version it does not hold today. When every
370
+ // requester asks through an open range (`*`, `0.83 - 0.86`), its current
371
+ // version is an accident of resolution rather than a constraint: the
372
+ // resolver is free to land it elsewhere, and which version that is is not
373
+ // ours to name. Anything exactly pinned, or pinned to the line it already
374
+ // carries (`^0.87.0` on an 0.87.0), is not re-resolvable.
375
+ const isReResolvable = (member: string): boolean => {
376
+ const ranges = externalRangesOn(member);
377
+ if (ranges.length === 0) return true;
378
+
379
+ const floors = ranges.map(rangeFloor);
380
+ if (floors.includes(null)) return false;
381
+
382
+ const highestFloor = floors
383
+ .filter((floor): floor is string => floor !== null)
384
+ .toSorted((a, b) => semver.rcompare(a, b))[0]!;
385
+
386
+ return (
387
+ ranges.every((range) => accepts(highestFloor, range)) &&
388
+ !isInstalled(member, highestFloor)
389
+ );
390
+ };
391
+
392
+ const duplicatedSet = new Set(duplicatedMembers);
393
+
394
+ // Members that end up on `version`, as a fixed point: start from those free
395
+ // to move — a duplicated one also has to already carry the version, since
396
+ // the pure-lock pass copies an existing entry and never fetches — then drop
397
+ // whoever an in-cluster requester pins elsewhere. Iterating matters because
398
+ // such a requester only stops pinning if it converges too, and the family's
399
+ // dependency edges run both ways.
400
+ // Members whose version the resolver will choose: they hold a version the
401
+ // family is leaving, and nothing pins them to it. Their current pins do not
402
+ // block the family — the install round-trip moves them — but the version
403
+ // they land on has to be verified afterwards, not asserted here.
404
+ const floatingFor = (version: string): Set<string> =>
405
+ new Set(
406
+ clusterMembers.filter(
407
+ (member) =>
408
+ // a duplicated member is what the fix is about: it converges or it
409
+ // is excluded, never "the resolver will sort it out"
410
+ !duplicatedSet.has(member) &&
411
+ !isInstalled(member, version) &&
412
+ !canReResolve(member, version) &&
413
+ isReResolvable(member),
414
+ ),
415
+ );
416
+
417
+ const convergentSetFor = (
418
+ version: string,
419
+ floating: Set<string>,
420
+ ): Set<string> => {
421
+ const converging = new Set(
422
+ clusterMembers.filter(
423
+ (member) =>
424
+ canMove(member, version) &&
425
+ canReResolve(member, version) &&
426
+ (!duplicatedSet.has(member) || isInstalled(member, version)),
427
+ ),
428
+ );
429
+
430
+ let settled = false;
431
+ while (!settled) {
432
+ settled = true;
433
+ for (const member of converging) {
434
+ const pinnedElsewhere = (internalByMember.get(member) ?? []).some(
435
+ (internal) =>
436
+ !accepts(version, internal.range) &&
437
+ !converging.has(internal.requesterName) &&
438
+ !floating.has(internal.requesterName),
439
+ );
440
+ if (pinnedElsewhere) {
441
+ converging.delete(member);
442
+ settled = false;
443
+ }
444
+ }
445
+ }
446
+
447
+ return converging;
448
+ };
449
+
450
+ const candidates = candidateVersions.map((version): Candidate => {
451
+ const floating = floatingFor(version);
452
+ const converging = convergentSetFor(version, floating);
453
+
454
+ return {
455
+ version,
456
+ floatingMembers: [...floating].toSorted((a, b) => a.localeCompare(b)),
457
+ // a pin the user would have to edit makes a candidate a last resort
458
+ pinsToEdit: workspaceConstraints.filter(
459
+ (constraint) =>
460
+ !floating.has(constraint.packageName) &&
461
+ !accepts(version, constraint.range),
462
+ ),
463
+ convergentMembers: duplicatedMembers.filter((member) =>
464
+ converging.has(member),
465
+ ),
466
+ excluded: new Map<string, ClusterExternalConstraint[]>(
467
+ duplicatedMembers
468
+ .filter((member) => !converging.has(member))
469
+ .map((member) => [member, rejectingConstraints(member, version)]),
470
+ ),
471
+ installedCount: [...converging].filter((member) =>
472
+ isInstalled(member, version),
473
+ ).length,
474
+ };
475
+ });
476
+
477
+ // The version an exact workspace pin fixes the family at, used to spot open
478
+ // ranges that ignored it. Ambiguous pins (two members pinned to different
479
+ // versions) anchor nothing. A pin is preferred, not immutable: a candidate
480
+ // that violates one is ranked last, and proposed as a `workspaceChanges`
481
+ // edit when nothing else deduplicates anything.
482
+ const anchor = ((): string | null => {
483
+ const pinned = new Set(
484
+ exactWorkspacePins
485
+ .filter((constraint) =>
486
+ isInstalled(constraint.packageName, constraint.range),
487
+ )
488
+ .map((constraint) => constraint.range),
489
+ );
490
+ return pinned.size === 1 ? [...pinned][0]! : null;
491
+ })();
492
+
493
+ // Open ranges that accept the anchored version, carry the anchored version
494
+ // in the lock, and still resolved somewhere else.
495
+ const reuseFixes: ClusterReuseFix[] =
496
+ anchor === null
497
+ ? []
498
+ : clusterMembers.flatMap((member) =>
499
+ isInstalled(member, anchor)
500
+ ? (bindingByMember.get(member) ?? []).flatMap((constraint) =>
501
+ constraint.requesterName !== undefined &&
502
+ constraint.resolvedVersion !== undefined &&
503
+ !semver.eq(constraint.resolvedVersion, anchor) &&
504
+ accepts(anchor, constraint.range)
505
+ ? [
506
+ {
507
+ requester: constraint.requester,
508
+ requesterName: constraint.requesterName,
509
+ packageName: member,
510
+ range: constraint.range,
511
+ from: constraint.resolvedVersion,
512
+ to: anchor,
513
+ },
514
+ ]
515
+ : [],
516
+ )
517
+ : [],
518
+ );
519
+
520
+ let best: Candidate | undefined;
521
+ for (const candidate of candidates) {
522
+ best = best ? betterCandidate(best, candidate) : candidate;
523
+ }
524
+
525
+ if (!best || best.convergentMembers.length === 0) {
526
+ fixes.push({
527
+ members: clusterMembers,
528
+ duplicatedMembers,
529
+ memberVersions,
530
+ target: null,
531
+ direction: "none",
532
+ convergentMembers: [],
533
+ driverMembers: [],
534
+ excludedMembers: [],
535
+ anchor,
536
+ reuseFixes,
537
+ floatingMembers: [],
538
+ workspaceChanges: [],
539
+ reResolutionSet: [],
540
+ externalConstraints,
541
+ needsRoundTrip: false,
542
+ applicable: false,
543
+ });
544
+ continue;
545
+ }
546
+
547
+ const target = best.version;
548
+
549
+ const workspaceChanges = best.pinsToEdit
550
+ .filter((constraint) => canMove(constraint.packageName, target))
551
+ .map((constraint) => ({ ...constraint, to: target }));
552
+
553
+ // re-resolution set: members free to move that are pulled from outside the
554
+ // cluster and do not carry the target version, so a pure-lock copy is
555
+ // impossible. Internal-only members cascade from these.
556
+ const floatingSet = new Set(best.floatingMembers);
557
+ const reResolutionSet = clusterMembers
558
+ .filter(
559
+ (member) =>
560
+ membersWithExternalDependent.has(member) &&
561
+ !floatingSet.has(member) &&
562
+ canMove(member, target) &&
563
+ canReResolve(member, target) &&
564
+ !isInstalled(member, target),
565
+ )
566
+ .toSorted((a, b) => a.localeCompare(b));
567
+
568
+ const replacedVersions = [
569
+ ...best.convergentMembers,
570
+ ...reResolutionSet,
571
+ ].flatMap((member) =>
572
+ npmVersionsOf(member).filter((version) => !semver.eq(version, target)),
573
+ );
574
+
575
+ fixes.push({
576
+ members: clusterMembers,
577
+ duplicatedMembers,
578
+ memberVersions,
579
+ target,
580
+ direction: directionOf(replacedVersions, target),
581
+ convergentMembers: best.convergentMembers,
582
+ driverMembers: best.convergentMembers.filter(hasRealConstraint),
583
+ excludedMembers: [...best.excluded].map(([packageName, blockedBy]) => ({
584
+ packageName,
585
+ blockedBy,
586
+ })),
587
+ anchor,
588
+ reuseFixes,
589
+ floatingMembers: best.floatingMembers,
590
+ workspaceChanges,
591
+ reResolutionSet,
592
+ needsRoundTrip:
593
+ reResolutionSet.length > 0 ||
594
+ workspaceChanges.length > 0 ||
595
+ best.floatingMembers.length > 0,
596
+ externalConstraints,
597
+ applicable: true,
598
+ });
599
+ }
600
+
601
+ return fixes;
602
+ };