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,156 @@
1
+ import semver from "semver";
2
+
3
+ /**
4
+ * Combination of an optional scope and name.
5
+ *
6
+ * eg `@npm/types`
7
+ */
8
+ export interface PackageDescriptorName {
9
+ scope?: string;
10
+ name: string;
11
+ }
12
+
13
+ interface DescriptorUtils<Descriptor> {
14
+ parse: (value: string) => Descriptor;
15
+ stringify: (descriptor: Descriptor) => string;
16
+ }
17
+
18
+ export const PackageDescriptorNameUtils: DescriptorUtils<PackageDescriptorName> =
19
+ {
20
+ parse: (value) => {
21
+ if (value.startsWith("@")) {
22
+ const [scope, name] = value.slice(1).split("/", 2) as [string, string];
23
+ if (`@${scope}/${name}` !== value) {
24
+ throw new Error(
25
+ `Invalid package name with scope: ${value} (expecting ${scope}/${name})`,
26
+ );
27
+ }
28
+ return { scope, name };
29
+ }
30
+ return { name: value };
31
+ },
32
+ stringify: (descriptor) => {
33
+ return descriptor.scope === undefined
34
+ ? descriptor.name
35
+ : `@${descriptor.scope}/${descriptor.name}`;
36
+ },
37
+ };
38
+
39
+ export interface PackageDependencyDescriptor<Protocol extends string = string> {
40
+ key: string;
41
+ npmName: string;
42
+ nameDescriptor: PackageDescriptorName;
43
+ /** `undefined` when the value is declared bare (`^1.0.0`), which means npm */
44
+ protocol: Protocol | undefined;
45
+ /** the npm name comes from the value (`npm:other@^1`), not from the key */
46
+ isAlias: boolean;
47
+ /** protocol removed: `^1.0.0` for `npm:^1.0.0`, `*` for `workspace:*` */
48
+ selector: string;
49
+ }
50
+
51
+ /**
52
+ * `git+ssh` and `https` keep their whole prefix rather than collapsing to `git`
53
+ * or being dropped: the round trip through `stringify` has to reproduce the
54
+ * declaration byte for byte, and nothing downstream branches on which flavour
55
+ * of git url it is.
56
+ */
57
+ const protocolPattern = /^([a-z][a-z0-9+.-]*):/u;
58
+
59
+ interface ExtractedProtocol {
60
+ protocol: string | undefined;
61
+ rest: string;
62
+ }
63
+
64
+ const extractProtocol = (dependencyValue: string): ExtractedProtocol => {
65
+ const match = protocolPattern.exec(dependencyValue);
66
+ if (!match) return { protocol: undefined, rest: dependencyValue };
67
+ return { protocol: match[1]!, rest: dependencyValue.slice(match[0].length) };
68
+ };
69
+
70
+ interface AliasParts {
71
+ npmName: string;
72
+ selector: string;
73
+ }
74
+
75
+ /**
76
+ * An `npm:` value is either a range (`npm:^1.0.0`, yarn's spelling) or an alias
77
+ * naming another package (`npm:semver@^6.0.0`, `npm:@types/bun`). Semver tells
78
+ * them apart: an alias target is never a valid range. A nameless alias keeps an
79
+ * empty selector, which `semver` already reads as `*`.
80
+ */
81
+ const parseAlias = (rest: string): AliasParts | undefined => {
82
+ if (semver.validRange(rest) !== null) return undefined;
83
+
84
+ const separatorIndex = rest.startsWith("@")
85
+ ? rest.indexOf("@", 1)
86
+ : rest.indexOf("@");
87
+
88
+ return separatorIndex === -1
89
+ ? { npmName: rest, selector: "" }
90
+ : {
91
+ npmName: rest.slice(0, separatorIndex),
92
+ selector: rest.slice(separatorIndex + 1),
93
+ };
94
+ };
95
+
96
+ export const PackageDependencyDescriptorUtils = {
97
+ make: <Protocol extends string>(
98
+ descriptor: PackageDependencyDescriptor<Protocol>,
99
+ selector: string,
100
+ ): PackageDependencyDescriptor<Protocol> => ({ ...descriptor, selector }),
101
+
102
+ /**
103
+ * The `Protocol` union declares what a given lockfile can contain; `parse`
104
+ * does not enforce it. An unknown protocol has to parse, because
105
+ * `manifestKeyOf` reads every sibling declaration in a dependency block to
106
+ * find the one it edits — throwing on a neighbour would abort an unrelated
107
+ * rewrite. It still lands in `protocol` rather than being folded into the
108
+ * selector, so `isNpmProtocol` keeps it out of the npm comparisons.
109
+ */
110
+ parse: <Protocol extends string = string>(
111
+ dependencyKey: string,
112
+ dependencyValue: string,
113
+ ): PackageDependencyDescriptor<Protocol> => {
114
+ const { protocol, rest } = extractProtocol(dependencyValue);
115
+ const alias = protocol === "npm" ? parseAlias(rest) : undefined;
116
+ const npmName = alias?.npmName ?? dependencyKey;
117
+
118
+ return {
119
+ key: dependencyKey,
120
+ npmName,
121
+ nameDescriptor: PackageDescriptorNameUtils.parse(npmName),
122
+ protocol: protocol as Protocol | undefined,
123
+ isAlias: alias !== undefined,
124
+ selector: alias?.selector ?? rest,
125
+ };
126
+ },
127
+
128
+ stringify: ({
129
+ key,
130
+ npmName,
131
+ protocol,
132
+ isAlias,
133
+ selector,
134
+ }: PackageDependencyDescriptor): [key: string, value: string] => {
135
+ const target = (() => {
136
+ if (!isAlias) return selector;
137
+ return selector === "" ? npmName : `${npmName}@${selector}`;
138
+ })();
139
+
140
+ return [key, protocol === undefined ? target : `${protocol}:${target}`];
141
+ },
142
+ };
143
+
144
+ export const isNpmProtocol = (protocol: string | undefined): boolean =>
145
+ protocol === undefined || protocol === "npm";
146
+
147
+ /**
148
+ * The guard to use before handing a selector to semver. A protocol check alone
149
+ * is not enough: shorthand declarations carry no prefix at all (`user/repo`,
150
+ * `./local`, `git@host:o/r.git`) and would otherwise read as npm ranges.
151
+ */
152
+ export const isSemverComparable = (
153
+ descriptor: PackageDependencyDescriptor,
154
+ ): boolean =>
155
+ isNpmProtocol(descriptor.protocol) &&
156
+ semver.validRange(descriptor.selector) !== null;
@@ -0,0 +1,116 @@
1
+ import { describe, it } from "bun:test";
2
+ import { deepStrictEqual } from "node:assert/strict";
3
+ import { parseArgs } from "node:util";
4
+ import {
5
+ toPackageFilterOptions,
6
+ toWhyDuplicateRequest,
7
+ whyDuplicateParseArgsOptions,
8
+ } from "./packageFilterArgs.ts";
9
+
10
+ const parseFlags = (args: string[]) =>
11
+ parseArgs({
12
+ args,
13
+ options: { ...whyDuplicateParseArgsOptions },
14
+ allowPositionals: true,
15
+ });
16
+
17
+ const parse = (args: string[]) =>
18
+ toPackageFilterOptions(parseFlags(args).values);
19
+
20
+ const request = (args: string[]) => {
21
+ const { values, positionals } = parseFlags(args);
22
+ return toWhyDuplicateRequest(values, positionals);
23
+ };
24
+
25
+ describe("toPackageFilterOptions", () => {
26
+ it("leaves every list undefined when no flag is passed", () => {
27
+ deepStrictEqual(parse([]), {
28
+ include: undefined,
29
+ includeScopes: undefined,
30
+ exclude: undefined,
31
+ excludeScopes: undefined,
32
+ });
33
+ });
34
+
35
+ it("reads the four flags", () => {
36
+ deepStrictEqual(
37
+ parse([
38
+ "--packages",
39
+ "lodash",
40
+ "--scopes",
41
+ "@babel",
42
+ "--exclude",
43
+ "react",
44
+ "--exclude-scopes",
45
+ "@types",
46
+ ]),
47
+ {
48
+ include: ["lodash"],
49
+ includeScopes: ["@babel"],
50
+ exclude: ["react"],
51
+ excludeScopes: ["@types"],
52
+ },
53
+ );
54
+ });
55
+
56
+ it("reads a repeated flag and a comma list the same way", () => {
57
+ const repeated = parse(["--packages", "lodash", "--packages", "react"]);
58
+ const commaSeparated = parse(["--packages", "lodash,react"]);
59
+
60
+ deepStrictEqual(repeated.include, ["lodash", "react"]);
61
+ deepStrictEqual(commaSeparated.include, ["lodash", "react"]);
62
+ });
63
+
64
+ it("drops the blanks a trailing comma or a stray space leaves behind", () => {
65
+ deepStrictEqual(parse(["--packages", " lodash , ,"]).include, ["lodash"]);
66
+ deepStrictEqual(parse(["--packages", ","]).include, undefined);
67
+ });
68
+ });
69
+
70
+ describe("toWhyDuplicateRequest", () => {
71
+ it("takes a positional as an included name", () => {
72
+ deepStrictEqual(request(["lodash"]), {
73
+ filter: {
74
+ include: ["lodash"],
75
+ includeScopes: undefined,
76
+ exclude: undefined,
77
+ excludeScopes: undefined,
78
+ },
79
+ explains: true,
80
+ all: false,
81
+ // naming a package is asking why, which the one-line form does not answer
82
+ details: true,
83
+ });
84
+ });
85
+
86
+ it("merges the positionals with the names the flag carries", () => {
87
+ deepStrictEqual(request(["--packages", "lodash", "react"]).filter.include, [
88
+ "lodash",
89
+ "react",
90
+ ]);
91
+ });
92
+
93
+ it("explains a scope with nothing named", () => {
94
+ const { filter, explains } = request(["--scopes", "@babel"]);
95
+
96
+ deepStrictEqual(filter.include, undefined);
97
+ deepStrictEqual(filter.includeScopes, ["@babel"]);
98
+ deepStrictEqual(explains, true);
99
+ });
100
+
101
+ it("lists the whole lockfile when only exclusions are given", () => {
102
+ deepStrictEqual(request(["--exclude", "lodash"]).explains, false);
103
+ deepStrictEqual(request([]).explains, false);
104
+ });
105
+
106
+ it("leaves a listing on one line per package until --details is asked for", () => {
107
+ deepStrictEqual(request([]).details, false);
108
+ deepStrictEqual(request(["--details"]).details, true);
109
+ deepStrictEqual(request(["-d"]).details, true);
110
+ });
111
+
112
+ it("reads --all", () => {
113
+ deepStrictEqual(request(["lodash"]).all, false);
114
+ deepStrictEqual(request(["lodash", "--all"]).all, true);
115
+ });
116
+ });
@@ -0,0 +1,98 @@
1
+ import type { PackageFilterOptions } from "./createPackageFilter.ts";
2
+
3
+ // `node:util` parseArgs shape, spread into a bin's own options so the four
4
+ // filter flags read the same whatever the package manager.
5
+ export const packageFilterParseArgsOptions = {
6
+ packages: { type: "string", multiple: true },
7
+ scopes: { type: "string", multiple: true },
8
+ exclude: { type: "string", multiple: true },
9
+ "exclude-scopes": { type: "string", multiple: true },
10
+ } as const;
11
+
12
+ // the `--help` block for the flags above, so every tool documents them alike
13
+ export const packageFilterUsage = ` --packages <names> only these packages, as names or globs
14
+ --scopes <scopes> only these scopes
15
+ --exclude <names> never these packages
16
+ --exclude-scopes <scopes> never these scopes
17
+
18
+ Every filter flag takes a comma list, or is repeated.`;
19
+
20
+ export interface PackageFilterArgValues {
21
+ packages?: string[];
22
+ scopes?: string[];
23
+ exclude?: string[];
24
+ "exclude-scopes"?: string[];
25
+ }
26
+
27
+ // A repeated flag is how parseArgs collects several values; a comma list is what
28
+ // people type. Both mean the same thing here.
29
+ const toNames = (values: string[] | undefined): string[] | undefined => {
30
+ if (!values) return undefined;
31
+ const names = values
32
+ .flatMap((value) => value.split(","))
33
+ .map((name) => name.trim())
34
+ .filter((name) => name !== "");
35
+ return names.length > 0 ? names : undefined;
36
+ };
37
+
38
+ export const toPackageFilterOptions = (
39
+ values: PackageFilterArgValues,
40
+ ): PackageFilterOptions => ({
41
+ include: toNames(values.packages),
42
+ includeScopes: toNames(values.scopes),
43
+ exclude: toNames(values.exclude),
44
+ excludeScopes: toNames(values["exclude-scopes"]),
45
+ });
46
+
47
+ // the two flags every `why-duplicate` bin takes on top of the filters
48
+ export const whyDuplicateParseArgsOptions = {
49
+ all: { type: "boolean", short: "a" },
50
+ details: { type: "boolean", short: "d" },
51
+ ...packageFilterParseArgsOptions,
52
+ } as const;
53
+
54
+ export const whyDuplicateUsage = ` -a, --all keep packages that are not duplicated
55
+ -d, --details every dependent of every version, not one line each
56
+
57
+ ${packageFilterUsage}`;
58
+
59
+ export interface WhyDuplicateArgValues extends PackageFilterArgValues {
60
+ all?: boolean;
61
+ details?: boolean;
62
+ }
63
+
64
+ export interface WhyDuplicateRequest {
65
+ filter: PackageFilterOptions;
66
+ // false lists the whole lockfile instead of explaining a selection
67
+ explains: boolean;
68
+ all: boolean;
69
+ // the whole tree rather than one line per package
70
+ details: boolean;
71
+ }
72
+
73
+ /**
74
+ * The positional a `why-duplicate` bin takes is the short form of `--packages`.
75
+ * Naming something to explain is what separates the two reports: without it
76
+ * there is nothing to be `--all` about, and the whole lockfile is listed.
77
+ *
78
+ * Naming one is also asking why it is duplicated, which the one-line form does
79
+ * not answer — so an explained selection is detailed unless it is a listing.
80
+ */
81
+ export const toWhyDuplicateRequest = (
82
+ values: WhyDuplicateArgValues,
83
+ positionals: string[],
84
+ ): WhyDuplicateRequest => {
85
+ const filter = toPackageFilterOptions(values);
86
+ const include = [...(filter.include ?? []), ...positionals];
87
+ const explains = include.length > 0 || filter.includeScopes !== undefined;
88
+
89
+ return {
90
+ filter: {
91
+ ...filter,
92
+ include: include.length > 0 ? include : undefined,
93
+ },
94
+ explains,
95
+ all: values.all ?? false,
96
+ details: values.details === true || explains,
97
+ };
98
+ };
@@ -0,0 +1,77 @@
1
+ import { afterEach, describe, it } from "bun:test";
2
+ import { ok, strictEqual } from "node:assert/strict";
3
+ import { stylePackageName, stylePackageReference } from "./packageStyles.ts";
4
+ import { createColorize } from "./reportColors.ts";
5
+
6
+ const environment = { ...process.env };
7
+
8
+ afterEach(() => {
9
+ process.env = { ...environment };
10
+ });
11
+
12
+ const colorize = createColorize(true);
13
+ // eslint-disable-next-line no-control-regex
14
+ const escapePattern = /\u001B\[[\d;]+m/gu;
15
+ const plainText = (text: string): string => text.replaceAll(escapePattern, "");
16
+
17
+ describe("stylePackageName", () => {
18
+ it("returns the name untouched when colour is off", () => {
19
+ strictEqual(
20
+ stylePackageName(createColorize(false), "@babel/core"),
21
+ "@babel/core",
22
+ );
23
+ });
24
+
25
+ it("styles the scope apart from the rest of the name", () => {
26
+ const styled = stylePackageName(colorize, "@babel/core");
27
+ strictEqual(plainText(styled), "@babel/core");
28
+ // the two are not in the same run of escape codes
29
+ ok(!styled.includes("@babel/core"));
30
+ });
31
+
32
+ it("renders the scope and the name in two shades of one hue", () => {
33
+ delete process.env.COLORTERM;
34
+ const styled = stylePackageName(colorize, "@babel/core");
35
+ ok(styled.startsWith("\u001B[38;5;166m@babel/"));
36
+ ok(styled.includes("\u001B[38;5;173mcore"));
37
+ });
38
+
39
+ it("switches to 24-bit colour when the terminal advertises it", () => {
40
+ process.env.COLORTERM = "truecolor";
41
+ ok(
42
+ stylePackageName(colorize, "metro").startsWith("\u001B[38;2;215;135;95m"),
43
+ );
44
+ });
45
+
46
+ it("styles an unscoped name in one run", () => {
47
+ strictEqual(plainText(stylePackageName(colorize, "metro")), "metro");
48
+ });
49
+ });
50
+
51
+ describe("stylePackageReference", () => {
52
+ it("splits the name from the version", () => {
53
+ const styled = stylePackageReference(colorize, "metro@0.84.5");
54
+ strictEqual(plainText(styled), "metro@0.84.5");
55
+ ok(!styled.includes("metro@"));
56
+ });
57
+
58
+ it("splits past the scope", () => {
59
+ const styled = stylePackageReference(colorize, "@babel/core@7.28.0");
60
+ strictEqual(plainText(styled), "@babel/core@7.28.0");
61
+ ok(!styled.includes("core@"));
62
+ });
63
+
64
+ it("keeps a peer suffix with the version", () => {
65
+ const styled = stylePackageReference(colorize, "metro@0.84.5(react@19)");
66
+ strictEqual(plainText(styled), "metro@0.84.5(react@19)");
67
+ ok(!styled.includes("metro@"));
68
+ });
69
+
70
+ it("leaves a reference carrying no version untouched", () => {
71
+ strictEqual(
72
+ stylePackageReference(colorize, "package.json in devDependencies"),
73
+ "package.json in devDependencies",
74
+ );
75
+ strictEqual(stylePackageReference(colorize, "@babel/core"), "@babel/core");
76
+ });
77
+ });
@@ -0,0 +1,42 @@
1
+ import type { Colorize, ReportStyle } from "./reportColors.ts";
2
+
3
+ /**
4
+ * A package name in two tones: the scope one shade deeper than the name it
5
+ * prefixes, so the half that identifies stays the readable one without the
6
+ * other fading out.
7
+ */
8
+ export const stylePackageName = (
9
+ color: Colorize,
10
+ name: string,
11
+ styles: ReportStyle[] = ["packageName"],
12
+ ): string => {
13
+ // 0 when unscoped, since indexOf returns -1
14
+ const scopeEnd = name.startsWith("@") ? name.indexOf("/") + 1 : 0;
15
+ if (scopeEnd === 0) return color(styles, name);
16
+ const scopeStyles = styles.map((style) =>
17
+ style === "packageName" ? "scope" : style,
18
+ );
19
+ return `${color(scopeStyles, name.slice(0, scopeEnd))}${color(styles, name.slice(scopeEnd))}`;
20
+ };
21
+
22
+ // The version part of `name@version`, past the scope's own `@`. Yarn's
23
+ // `lodash@npm:4.17.21` and pnpm's `metro@0.84.5(react@19)` both split here.
24
+ const versionStart = (reference: string): number => {
25
+ const nameStart = reference.startsWith("@") ? reference.indexOf("/") + 1 : 0;
26
+ if (nameStart === 0 && reference.startsWith("@")) return -1;
27
+ return reference.indexOf("@", nameStart);
28
+ };
29
+
30
+ /**
31
+ * A `name@version` reference. Anything without a version is returned untouched:
32
+ * the reports also list requesters that are not packages at all, such as
33
+ * `package.json in devDependencies`.
34
+ */
35
+ export const stylePackageReference = (
36
+ color: Colorize,
37
+ reference: string,
38
+ ): string => {
39
+ const at = versionStart(reference);
40
+ if (at <= 0) return reference;
41
+ return `${stylePackageName(color, reference.slice(0, at))}${color("yellow", reference.slice(at))}`;
42
+ };
@@ -0,0 +1,73 @@
1
+ import { afterEach, describe, it } from "bun:test";
2
+ import { deepStrictEqual, strictEqual } from "node:assert/strict";
3
+ import { parseBinArgs } from "./parseBinArgs.ts";
4
+
5
+ const usage = "Usage: tool [options]";
6
+
7
+ const originalLog = console.log;
8
+ const originalError = console.error;
9
+
10
+ afterEach(() => {
11
+ console.log = originalLog;
12
+ console.error = originalError;
13
+ });
14
+
15
+ // `process.exitCode` is the test runner's own, so the error path has to hand it
16
+ // back untouched.
17
+ const withCapturedOutput = <T>(run: () => T): { result: T; output: string } => {
18
+ const lines: string[] = [];
19
+ const record = (message = ""): void => {
20
+ lines.push(message);
21
+ };
22
+ console.log = record;
23
+ console.error = record;
24
+
25
+ const exitCodeBefore = process.exitCode;
26
+ try {
27
+ return { result: run(), output: lines.join("\n") };
28
+ } finally {
29
+ if (process.exitCode !== exitCodeBefore) {
30
+ // bun ignores `process.exitCode = undefined` (node resets it), so the
31
+ // whole run would end on 1 with every test passing.
32
+ process.exitCode = exitCodeBefore ?? 0;
33
+ }
34
+ }
35
+ };
36
+
37
+ describe("parseBinArgs", () => {
38
+ const parse = (args: string[]) =>
39
+ parseBinArgs(usage, {
40
+ args,
41
+ options: { check: { type: "boolean" } },
42
+ allowPositionals: true,
43
+ });
44
+
45
+ it("parses the arguments it knows", () => {
46
+ const parsed = parse(["--check", "lodash"]);
47
+
48
+ // `parseArgs` returns a null-prototype `values`, and `deepStrictEqual`
49
+ // compares prototypes.
50
+ deepStrictEqual(parsed?.values, { __proto__: null, check: true });
51
+ deepStrictEqual(parsed?.positionals, ["lodash"]);
52
+ });
53
+
54
+ it("prints the usage and stops on --help", () => {
55
+ const { result, output } = withCapturedOutput(() => parse(["--help"]));
56
+
57
+ strictEqual(result, null);
58
+ strictEqual(output, usage);
59
+ strictEqual(process.exitCode, undefined);
60
+ });
61
+
62
+ it("names an unknown flag and fails instead of ignoring it", () => {
63
+ const { result, output } = withCapturedOutput(() => {
64
+ const parsed = parse(["--chekc"]);
65
+ strictEqual(process.exitCode, 1);
66
+ return parsed;
67
+ });
68
+
69
+ strictEqual(result, null);
70
+ strictEqual(output.includes("--chekc"), true);
71
+ strictEqual(output.includes(usage), true);
72
+ });
73
+ });
@@ -0,0 +1,33 @@
1
+ import { parseArgs } from "node:util";
2
+
3
+ type ParseArgsConfig = NonNullable<Parameters<typeof parseArgs>[0]>;
4
+
5
+ /**
6
+ * `parseArgs` with the two things a bin still has to do itself: answer
7
+ * `--help`, and turn an unknown flag into a readable line instead of a stack
8
+ * trace. Rejecting the unknown flag matters more than it reads: a mistyped
9
+ * `--dry-run` silently ignored is a run that applies its changes.
10
+ *
11
+ * Returns null when the bin has nothing left to do — the usage was asked for,
12
+ * or the arguments were rejected and the exit code already says so.
13
+ */
14
+ export const parseBinArgs = <T extends ParseArgsConfig>(
15
+ usage: string,
16
+ config: T,
17
+ ): ReturnType<typeof parseArgs<T>> | null => {
18
+ const args = config.args ?? process.argv.slice(2);
19
+ if (args.includes("--help") || args.includes("-h")) {
20
+ console.log(usage);
21
+ return null;
22
+ }
23
+
24
+ try {
25
+ return parseArgs(config);
26
+ } catch (error) {
27
+ console.error(error instanceof Error ? error.message : String(error));
28
+ console.error();
29
+ console.error(usage);
30
+ process.exitCode = 1;
31
+ return null;
32
+ }
33
+ };