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,159 @@
1
+ import { describe, it } from "bun:test";
2
+ import { strictEqual } from "node:assert/strict";
3
+ import { applyWorkspaceRangeEdit, nextSelector } from "./workspaceManifest.js";
4
+ const manifest = [
5
+ "{",
6
+ ' "name": "root",',
7
+ ' "dependencies": {',
8
+ ' "metro": "0.84.5"',
9
+ " },",
10
+ ' "devDependencies": {',
11
+ ' "metro": "^0.84.5",',
12
+ ' "lodash.merge": "~4.6.0",',
13
+ ' "aliased": "npm:metro@^0.84.5"',
14
+ " }",
15
+ "}",
16
+ "",
17
+ ].join("\n");
18
+ // a block whose other declarations carry protocols the parser has no list for:
19
+ // they have to parse so the target rewrite is not aborted by a neighbour
20
+ const manifestWithProtocols = [
21
+ "{",
22
+ ' "name": "root",',
23
+ ' "dependencies": {',
24
+ ' "local": "workspace:^1.0.0",',
25
+ ' "vendored": "someproto:whatever",',
26
+ ' "forked": "git+ssh://git@host/r.git#v1",',
27
+ ' "metro": "^0.84.5"',
28
+ " }",
29
+ "}",
30
+ "",
31
+ ].join("\n");
32
+ describe("nextSelector", () => {
33
+ it("keeps a caret or tilde on the target line", () => {
34
+ strictEqual(nextSelector("^0.84.5", "0.87.0"), "^0.87.0");
35
+ strictEqual(nextSelector("~4.6.0", "4.7.1"), "~4.7.1");
36
+ });
37
+ it("pins exactly when the range has no target-line equivalent", () => {
38
+ strictEqual(nextSelector("0.84.5", "0.87.0"), "0.87.0");
39
+ strictEqual(nextSelector("*", "0.87.0"), "0.87.0");
40
+ strictEqual(nextSelector("0.83 - 0.86", "0.87.0"), "0.87.0");
41
+ strictEqual(nextSelector("^0.83 - 0.86", "0.87.0"), "0.87.0");
42
+ });
43
+ });
44
+ describe("applyWorkspaceRangeEdit", () => {
45
+ it("edits only the declaration in the requested depType", () => {
46
+ strictEqual(applyWorkspaceRangeEdit(manifest, {
47
+ packageName: "metro",
48
+ depType: "devDependencies",
49
+ range: "^0.84.5",
50
+ to: "0.87.0",
51
+ }), manifest.replace('"metro": "^0.84.5"', '"metro": "^0.87.0"'));
52
+ });
53
+ it("edits the exact pin in dependencies without touching devDependencies", () => {
54
+ strictEqual(applyWorkspaceRangeEdit(manifest, {
55
+ packageName: "metro",
56
+ depType: "dependencies",
57
+ range: "0.84.5",
58
+ to: "0.87.0",
59
+ }), manifest.replace('"metro": "0.84.5"', '"metro": "0.87.0"'));
60
+ });
61
+ it("keeps the npm: alias prefix", () => {
62
+ strictEqual(applyWorkspaceRangeEdit(manifest, {
63
+ packageName: "metro",
64
+ depType: "devDependencies",
65
+ range: "^0.84.5",
66
+ to: "0.87.0",
67
+ })?.includes('"aliased": "npm:metro@^0.84.5"'), true);
68
+ strictEqual(applyWorkspaceRangeEdit(manifest, {
69
+ packageName: "metro",
70
+ depType: "devDependencies",
71
+ range: "^0.84.5",
72
+ to: "0.87.0",
73
+ })?.includes('"metro": "^0.87.0"'), true);
74
+ });
75
+ it("matches a dotted name literally", () => {
76
+ strictEqual(applyWorkspaceRangeEdit(manifest, {
77
+ packageName: "lodash.merge",
78
+ depType: "devDependencies",
79
+ range: "~4.6.0",
80
+ to: "4.7.1",
81
+ }), manifest.replace('"lodash.merge": "~4.6.0"', '"lodash.merge": "~4.7.1"'));
82
+ });
83
+ it("rewrites past siblings declared with protocols it does not know", () => {
84
+ strictEqual(applyWorkspaceRangeEdit(manifestWithProtocols, {
85
+ packageName: "metro",
86
+ depType: "dependencies",
87
+ range: "^0.84.5",
88
+ to: "0.87.0",
89
+ }), manifestWithProtocols.replace('"metro": "^0.84.5"', '"metro": "^0.87.0"'));
90
+ });
91
+ // the range reaches here already stripped of its protocol, and `stringify`
92
+ // puts it back: rewriting `workspace:^1.0.0` to a bare `2.0.0` would change
93
+ // which package the declaration points at
94
+ it("keeps a workspace protocol on the rewritten declaration", () => {
95
+ strictEqual(applyWorkspaceRangeEdit(manifestWithProtocols, {
96
+ packageName: "local",
97
+ depType: "dependencies",
98
+ range: "^1.0.0",
99
+ to: "2.0.0",
100
+ }), manifestWithProtocols.replace('"local": "workspace:^1.0.0"', '"local": "workspace:^2.0.0"'));
101
+ });
102
+ it("edits an aliased declaration through its own key", () => {
103
+ strictEqual(applyWorkspaceRangeEdit([
104
+ "{",
105
+ ' "dependencies": {',
106
+ ' "aliased": "npm:metro@^0.84.5"',
107
+ " }",
108
+ "}",
109
+ "",
110
+ ].join("\n"), {
111
+ packageName: "metro",
112
+ depType: "dependencies",
113
+ range: "^0.84.5",
114
+ to: "0.87.0",
115
+ })?.includes('"aliased": "npm:metro@^0.87.0"'), true);
116
+ });
117
+ // The manifest key `typescript` is an alias for another package entirely,
118
+ // while the real `typescript` is reached through `@typescript/native`. Keying
119
+ // the rewrite on the package name would edit the wrong line in both
120
+ // directions.
121
+ it("edits the key an alias reaches the package through, not its namesake", () => {
122
+ const swapped = [
123
+ "{",
124
+ ' "devDependencies": {',
125
+ ' "@typescript/native": "npm:typescript@^7.0.0",',
126
+ ' "typescript": "npm:@typescript/typescript6@6.0.2"',
127
+ " }",
128
+ "}",
129
+ "",
130
+ ].join("\n");
131
+ strictEqual(applyWorkspaceRangeEdit(swapped, {
132
+ packageName: "typescript",
133
+ depType: "devDependencies",
134
+ range: "^7.0.0",
135
+ to: "7.1.0",
136
+ }), swapped.replace('"npm:typescript@^7.0.0"', '"npm:typescript@^7.1.0"'));
137
+ strictEqual(applyWorkspaceRangeEdit(swapped, {
138
+ packageName: "@typescript/typescript6",
139
+ depType: "devDependencies",
140
+ range: "6.0.2",
141
+ to: "6.1.0",
142
+ }), swapped.replace('"npm:@typescript/typescript6@6.0.2"', '"npm:@typescript/typescript6@6.1.0"'));
143
+ });
144
+ it("reports a declaration that moved on since the lockfile was read", () => {
145
+ strictEqual(applyWorkspaceRangeEdit(manifest, {
146
+ packageName: "metro",
147
+ depType: "devDependencies",
148
+ range: "^0.80.0",
149
+ to: "0.87.0",
150
+ }), undefined);
151
+ strictEqual(applyWorkspaceRangeEdit(manifest, {
152
+ packageName: "metro",
153
+ depType: "optionalDependencies",
154
+ range: "^0.84.5",
155
+ to: "0.87.0",
156
+ }), undefined);
157
+ });
158
+ });
159
+ //# sourceMappingURL=workspaceManifest.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspaceManifest.test.js","sourceRoot":"","sources":["../src/workspaceManifest.test.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "pm-dedup-core",
3
+ "version": "2.1.0",
4
+ "description": "package manager utils",
5
+ "keywords": [],
6
+ "homepage": "https://github.com/christophehurpeau/pm-tools",
7
+ "bugs": {
8
+ "url": "https://github.com/christophehurpeau/pm-tools/issues"
9
+ },
10
+ "license": "MIT",
11
+ "author": "Christophe Hurpeau <christophe@hurpeau.com> (https://christophe.hurpeau.com)",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/christophehurpeau/pm-tools.git",
15
+ "directory": "packages/pm-dedup-core"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "dist"
20
+ ],
21
+ "type": "module",
22
+ "sideEffects": false,
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/definitions/index.d.ts",
25
+ "exports": {
26
+ "./package.json": "./package.json",
27
+ ".": {
28
+ "types": "./dist/definitions/index.d.ts",
29
+ "node": {
30
+ "import": "./dist/index.js"
31
+ }
32
+ }
33
+ },
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "lint": "bun run lint:oxlint && bun run lint:eslint",
37
+ "lint:eslint": "bun run --cwd '../..' eslint --quiet 'packages/pm-dedup-core'",
38
+ "lint:oxlint": "bun run --cwd '../..' oxlint --quiet 'packages/pm-dedup-core'",
39
+ "test": "bun run --cwd '../..' test -- 'packages/pm-dedup-core'",
40
+ "tsc": "tsc"
41
+ },
42
+ "dependencies": {
43
+ "picomatch": "4.0.7",
44
+ "semver": "7.8.5",
45
+ "tslib": "^2.8.1"
46
+ },
47
+ "devDependencies": {
48
+ "@types/bun": "1.4.2",
49
+ "@types/picomatch": "4.0.3",
50
+ "@typescript/native": "npm:typescript@7.0.2",
51
+ "typescript": "npm:@typescript/typescript6@6.0.2"
52
+ },
53
+ "engines": {
54
+ "node": ">=22.18.0"
55
+ },
56
+ "pob": {
57
+ "bundler": "tsc",
58
+ "entries": [
59
+ "index"
60
+ ],
61
+ "envs": [
62
+ {
63
+ "target": "node",
64
+ "version": "22"
65
+ }
66
+ ]
67
+ }
68
+ }
@@ -0,0 +1,22 @@
1
+ import {
2
+ type ResolutionDependent,
3
+ type ResolutionDependentsMap,
4
+ type ResolutionEntry,
5
+ type ResolutionFix,
6
+ identifyResolutionFixes,
7
+ } from "./identifyResolutionFixes.ts";
8
+
9
+ export type ResolutionsMap = Record<string, ResolutionEntry[]>;
10
+
11
+ export const buildIdentifiedFixesMap = <D extends ResolutionDependent>(
12
+ duplicatesResolutionsMap: ResolutionsMap,
13
+ dependents: ResolutionDependentsMap<D>,
14
+ ): Map<string, ResolutionFix[]> =>
15
+ new Map<string, ResolutionFix[]>(
16
+ Object.entries(duplicatesResolutionsMap).map(
17
+ ([packageName, resolutions]) => [
18
+ packageName,
19
+ identifyResolutionFixes(resolutions, dependents),
20
+ ],
21
+ ),
22
+ );
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ type LockstepGraph,
4
+ buildLockstepClusters,
5
+ } from "./buildLockstepClusters.ts";
6
+
7
+ const npm = (
8
+ version: string,
9
+ dependencies: Record<string, string> = {},
10
+ ): { version: string; isNpm: true; dependencies: Record<string, string> } => ({
11
+ version,
12
+ isNpm: true,
13
+ dependencies,
14
+ });
15
+
16
+ describe("buildLockstepClusters", () => {
17
+ it("unions a co-versioned family across caret and exact pins", () => {
18
+ // a@1 and a@2 both request b/c at their own version; b requests c at its
19
+ // own version. `noise` is requested at an unrelated version by everyone.
20
+ const graph: LockstepGraph = {
21
+ a: [
22
+ npm("1.0.0", { b: "1.0.0", c: "^1.0.0", noise: "^3.0.0" }),
23
+ npm("2.0.0", { b: "2.0.0", c: "^2.0.0", noise: "^3.0.0" }),
24
+ ],
25
+ b: [npm("1.0.0", { c: "1.0.0" }), npm("2.0.0", { c: "2.0.0" })],
26
+ c: [npm("1.0.0"), npm("2.0.0")],
27
+ noise: [npm("3.1.0"), npm("3.2.0")],
28
+ };
29
+
30
+ const clusters = buildLockstepClusters(graph);
31
+
32
+ expect(clusters).toEqual([["a", "b", "c"]]);
33
+ });
34
+
35
+ it("does not union when a version observation breaks co-version", () => {
36
+ // a@2 requests b at b's matching version, but a@1 requests b at a stale
37
+ // version: not every observation co-versions, so no union.
38
+ const graph: LockstepGraph = {
39
+ a: [npm("1.0.0", { b: "2.0.0" }), npm("2.0.0", { b: "2.0.0" })],
40
+ b: [npm("1.0.0"), npm("2.0.0")],
41
+ };
42
+
43
+ expect(buildLockstepClusters(graph)).toEqual([]);
44
+ });
45
+
46
+ it("does not union when the anchor version is not installed", () => {
47
+ // Real case: `d@1.0.1` requests `type@^1.0.1`, so the range anchors on
48
+ // d's own version by coincidence — no `type@1.0.1` is installed, the
49
+ // request resolves to `type@1.2.0`, and the two do not move together.
50
+ const graph: LockstepGraph = {
51
+ d: [npm("1.0.1", { type: "^1.0.1" })],
52
+ esniff: [npm("2.0.1", { d: "^1.0.1", type: "^2.7.2" })],
53
+ type: [npm("1.2.0"), npm("2.7.2")],
54
+ };
55
+
56
+ expect(buildLockstepClusters(graph)).toEqual([]);
57
+ });
58
+
59
+ it("unions when the anchor version is installed alongside others", () => {
60
+ // Same shape, but the lock does carry `b@1.0.0`: the pin is real, and the
61
+ // duplicate `b@1.2.0` is what the cluster pass exists to collapse.
62
+ const graph: LockstepGraph = {
63
+ a: [npm("1.0.0", { b: "^1.0.0" })],
64
+ b: [npm("1.0.0"), npm("1.2.0")],
65
+ };
66
+
67
+ expect(buildLockstepClusters(graph)).toEqual([["a", "b"]]);
68
+ });
69
+
70
+ it("keeps unioning pnpm-style edges that store the resolved version", () => {
71
+ // pnpm records the resolved version, so the anchor is installed by
72
+ // construction and detection is unchanged.
73
+ const graph: LockstepGraph = {
74
+ a: [npm("7.28.0", { b: "7.28.0" })],
75
+ b: [npm("7.28.0")],
76
+ };
77
+
78
+ expect(buildLockstepClusters(graph)).toEqual([["a", "b"]]);
79
+ });
80
+
81
+ it("ignores edges to packages absent from the graph and non-npm resolutions", () => {
82
+ const graph: LockstepGraph = {
83
+ a: [npm("1.0.0", { missing: "1.0.0", b: "1.0.0" })],
84
+ b: [npm("1.0.0")],
85
+ w: [{ version: "", isNpm: false, dependencies: { a: "1.0.0" } }],
86
+ };
87
+
88
+ expect(buildLockstepClusters(graph)).toEqual([["a", "b"]]);
89
+ });
90
+ });
@@ -0,0 +1,155 @@
1
+ import semver from "semver";
2
+
3
+ // One installed resolution of a package, reduced to what cluster detection
4
+ // needs. `dependencies` maps a dependency name to either a requested range
5
+ // (bun stores `^8.61.0`) or a resolved version (pnpm snapshots store `8.61.0`);
6
+ // both are handled by anchoring on the minimum version.
7
+ export interface LockstepResolution {
8
+ version: string;
9
+ isNpm: boolean;
10
+ dependencies: Record<string, string>;
11
+ }
12
+
13
+ // name -> installed resolutions. Package-manager packages adapt their own
14
+ // lockfile model into this neutral shape.
15
+ export type LockstepGraph = Record<string, LockstepResolution[]>;
16
+
17
+ interface UnionFind {
18
+ find: (value: string) => string;
19
+ union: (a: string, b: string) => void;
20
+ }
21
+
22
+ const createUnionFind = (names: string[]): UnionFind => {
23
+ const parent = new Map<string, string>(names.map((name) => [name, name]));
24
+
25
+ const find = (value: string): string => {
26
+ let root = value;
27
+ while (parent.get(root) !== root) {
28
+ root = parent.get(root)!;
29
+ }
30
+ let cursor = value;
31
+ while (parent.get(cursor) !== root) {
32
+ const next = parent.get(cursor)!;
33
+ parent.set(cursor, root);
34
+ cursor = next;
35
+ }
36
+ return root;
37
+ };
38
+
39
+ const union = (a: string, b: string): void => {
40
+ const rootA = find(a);
41
+ const rootB = find(b);
42
+ if (rootA !== rootB) {
43
+ parent.set(rootA, rootB);
44
+ }
45
+ };
46
+
47
+ return { find, union };
48
+ };
49
+
50
+ // npm versions installed for each name, normalized, for anchor lookups.
51
+ const collectNpmVersions = (graph: LockstepGraph): Map<string, Set<string>> => {
52
+ const versionsByName = new Map<string, Set<string>>();
53
+
54
+ for (const [name, resolutions] of Object.entries(graph)) {
55
+ const versions = new Set<string>();
56
+ for (const resolution of resolutions) {
57
+ if (!resolution.isNpm) continue;
58
+ const parsed = semver.parse(resolution.version);
59
+ if (parsed) versions.add(parsed.version);
60
+ }
61
+ versionsByName.set(name, versions);
62
+ }
63
+
64
+ return versionsByName;
65
+ };
66
+
67
+ // A dependency edge `A@version --(range)--> B` is "co-version" when the range
68
+ // is anchored on the same version A itself carries, regardless of the caret /
69
+ // tilde operator (`^8.61.0`, `8.61.0` and `~8.61.0` are all co-version with
70
+ // `8.61.0`). This distinguishes a lockstep-published family member from an
71
+ // ordinary external dependency such as `semver`/`debug`.
72
+ //
73
+ // The anchor also has to be a version of B the lock actually installs, or the
74
+ // equality is a coincidence rather than a lockstep pin: `d@1.0.1` requesting
75
+ // `type@^1.0.1` anchors on `1.0.1`, yet the only installed `type` versions are
76
+ // `1.2.0` and `2.7.2`, so `d` and `type` do not move together. Lockfiles that
77
+ // store resolved versions instead of ranges (pnpm) satisfy this by
78
+ // construction; it brings the range-storing ones in line.
79
+ const isCoVersion = (
80
+ version: string,
81
+ range: string,
82
+ depVersions: Set<string>,
83
+ ): boolean => {
84
+ const min = (() => {
85
+ try {
86
+ return semver.minVersion(range);
87
+ } catch {
88
+ return null;
89
+ }
90
+ })();
91
+ if (min === null) return false;
92
+ return semver.eq(min, version) && depVersions.has(min.version);
93
+ };
94
+
95
+ // Detect families of packages published in lockstep (versions move together)
96
+ // via union-find over the realized resolution graph. Names A and B are unioned
97
+ // when a real dependency edge A->B exists AND across every installed resolution
98
+ // where A requests B they carry the same version, that version being one the
99
+ // lock installs for B. Returns connected components
100
+ // of size > 1, members sorted, components ordered by their first member.
101
+ export const buildLockstepClusters = (graph: LockstepGraph): string[][] => {
102
+ const names = Object.keys(graph);
103
+ const unionFind = createUnionFind(names);
104
+ const versionsByName = collectNpmVersions(graph);
105
+
106
+ for (const [name, resolutions] of Object.entries(graph)) {
107
+ const observationsByDep = new Map<
108
+ string,
109
+ { version: string; range: string }[]
110
+ >();
111
+
112
+ for (const resolution of resolutions) {
113
+ if (!resolution.isNpm) continue;
114
+
115
+ for (const [depName, range] of Object.entries(resolution.dependencies)) {
116
+ if (depName === name) continue;
117
+ // edge must point to a package that actually exists in the lock
118
+ if (!graph[depName]) continue;
119
+
120
+ let observations = observationsByDep.get(depName);
121
+ if (!observations) {
122
+ observations = [];
123
+ observationsByDep.set(depName, observations);
124
+ }
125
+ observations.push({ version: resolution.version, range });
126
+ }
127
+ }
128
+
129
+ for (const [depName, observations] of observationsByDep) {
130
+ const depVersions = versionsByName.get(depName) ?? new Set<string>();
131
+ const allCoVersion = observations.every((observation) =>
132
+ isCoVersion(observation.version, observation.range, depVersions),
133
+ );
134
+ if (allCoVersion) {
135
+ unionFind.union(name, depName);
136
+ }
137
+ }
138
+ }
139
+
140
+ const componentsByRoot = new Map<string, string[]>();
141
+ for (const name of names) {
142
+ const root = unionFind.find(name);
143
+ let members = componentsByRoot.get(root);
144
+ if (!members) {
145
+ members = [];
146
+ componentsByRoot.set(root, members);
147
+ }
148
+ members.push(name);
149
+ }
150
+
151
+ return [...componentsByRoot.values()]
152
+ .filter((members) => members.length > 1)
153
+ .map((members) => members.toSorted((a, b) => a.localeCompare(b)))
154
+ .toSorted((a, b) => a[0]!.localeCompare(b[0]!));
155
+ };
@@ -0,0 +1,79 @@
1
+ import { describe, it } from "bun:test";
2
+ import { strictEqual } from "node:assert/strict";
3
+ import { clusterLabel } from "./clusterLabel.ts";
4
+
5
+ const typescriptEslint = [
6
+ "@typescript-eslint/eslint-plugin",
7
+ "@typescript-eslint/parser",
8
+ "@typescript-eslint/project-service",
9
+ "@typescript-eslint/scope-manager",
10
+ "@typescript-eslint/tsconfig-utils",
11
+ "@typescript-eslint/type-utils",
12
+ "@typescript-eslint/types",
13
+ "@typescript-eslint/typescript-estree",
14
+ "@typescript-eslint/utils",
15
+ "@typescript-eslint/visitor-keys",
16
+ "typescript-eslint",
17
+ ];
18
+
19
+ const metroFamily = [
20
+ "@react-native/asset-utils",
21
+ "@react-native/babel-plugin-codegen",
22
+ "@react-native/babel-preset",
23
+ "@react-native/codegen",
24
+ "metro",
25
+ "metro-cache",
26
+ "metro-config",
27
+ "metro-core",
28
+ "ob1",
29
+ "react-native",
30
+ ];
31
+
32
+ describe("clusterLabel", () => {
33
+ it("names a family by its shared scope", () => {
34
+ strictEqual(
35
+ clusterLabel(typescriptEslint),
36
+ "@typescript-eslint/* (+1 more)",
37
+ );
38
+ });
39
+
40
+ it("names the two largest groups and counts the rest", () => {
41
+ strictEqual(
42
+ clusterLabel(metroFamily),
43
+ "@react-native/* + metro* (+2 more)",
44
+ );
45
+ });
46
+
47
+ it("uses a dash prefix when no member carries the prefix itself", () => {
48
+ strictEqual(
49
+ clusterLabel([
50
+ "babel-plugin-syntax-hermes-parser",
51
+ "hermes-estree",
52
+ "hermes-parser",
53
+ ]),
54
+ "hermes-* (+1 more)",
55
+ );
56
+ });
57
+
58
+ it("keeps the member name when it is the prefix", () => {
59
+ strictEqual(
60
+ clusterLabel(["mini-metro", "mini-metro-config"]),
61
+ "mini-metro*",
62
+ );
63
+ });
64
+
65
+ it("names a single member exactly", () => {
66
+ strictEqual(clusterLabel(["metro"]), "metro");
67
+ });
68
+
69
+ it("does not name a second group that covers one member", () => {
70
+ strictEqual(
71
+ clusterLabel(["metro", "metro-config", "ob1"]),
72
+ "metro* (+1 more)",
73
+ );
74
+ });
75
+
76
+ it("returns an empty label for no members", () => {
77
+ strictEqual(clusterLabel([]), "");
78
+ });
79
+ });
@@ -0,0 +1,75 @@
1
+ const leadingPrefixes = (member: string): string[] => {
2
+ const scopeEnd = member.startsWith("@") ? member.indexOf("/") : -1;
3
+ const scope = scopeEnd === -1 ? null : member.slice(0, scopeEnd);
4
+ const rest = scope === null ? member : member.slice(scopeEnd + 1);
5
+
6
+ const segments = rest.split("-");
7
+ const withinRest = segments.map((_segment, index) => {
8
+ const joined = segments.slice(0, index + 1).join("-");
9
+ return scope === null ? joined : `${scope}/${joined}`;
10
+ });
11
+
12
+ return scope === null ? withinRest : [scope, ...withinRest];
13
+ };
14
+
15
+ const covers = (prefix: string, member: string): boolean =>
16
+ member === prefix ||
17
+ member.startsWith(`${prefix}-`) ||
18
+ member.startsWith(`${prefix}/`);
19
+
20
+ interface PrefixGroup {
21
+ prefix: string;
22
+ covered: string[];
23
+ }
24
+
25
+ /**
26
+ * The prefix shared by the most members. Ties go to the longest prefix, so a
27
+ * family named after a scope wins over the bare package name it also matches.
28
+ */
29
+ const bestGroup = (members: string[]): PrefixGroup | null => {
30
+ const candidates = [...new Set(members.flatMap(leadingPrefixes))];
31
+
32
+ let best: PrefixGroup | null = null;
33
+ for (const prefix of candidates.toSorted((a, b) => a.localeCompare(b))) {
34
+ const covered = members.filter((member) => covers(prefix, member));
35
+ if (
36
+ best === null ||
37
+ covered.length > best.covered.length ||
38
+ (covered.length === best.covered.length &&
39
+ prefix.length > best.prefix.length)
40
+ ) {
41
+ best = { prefix, covered };
42
+ }
43
+ }
44
+ return best;
45
+ };
46
+
47
+ const formatGroup = ({ prefix, covered }: PrefixGroup): string => {
48
+ if (covered.length === 1 && covered[0] === prefix) return prefix;
49
+ if (prefix.startsWith("@") && !prefix.includes("/")) return `${prefix}/*`;
50
+ return covered.includes(prefix) ? `${prefix}*` : `${prefix}-*`;
51
+ };
52
+
53
+ /**
54
+ * A readable identity for a lockstep cluster, derived from the prefixes its
55
+ * members share: `@typescript-eslint/* (+1 more)`, `metro* + @react-native/*`.
56
+ * A second group is only worth naming when it covers more than one member;
57
+ * anything left over is counted, never guessed at.
58
+ */
59
+ export const clusterLabel = (members: string[]): string => {
60
+ const first = bestGroup(members);
61
+ if (first === null) return "";
62
+
63
+ const groups = [first];
64
+ const remaining = members.filter((member) => !covers(first.prefix, member));
65
+
66
+ const second = remaining.length > 0 ? bestGroup(remaining) : null;
67
+ if (second !== null && second.covered.length > 1) groups.push(second);
68
+
69
+ const uncovered = members.filter(
70
+ (member) => !groups.some((group) => covers(group.prefix, member)),
71
+ ).length;
72
+
73
+ const label = groups.map(formatGroup).join(" + ");
74
+ return uncovered === 0 ? label : `${label} (+${uncovered} more)`;
75
+ };