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.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/buildIdentifiedFixesMap.js +6 -0
- package/dist/buildIdentifiedFixesMap.js.map +1 -0
- package/dist/buildLockstepClusters.js +119 -0
- package/dist/buildLockstepClusters.js.map +1 -0
- package/dist/buildLockstepClusters.test.js +71 -0
- package/dist/buildLockstepClusters.test.js.map +1 -0
- package/dist/clusterLabel.js +59 -0
- package/dist/clusterLabel.js.map +1 -0
- package/dist/clusterLabel.test.js +56 -0
- package/dist/clusterLabel.test.js.map +1 -0
- package/dist/compareCandidateVersions.js +18 -0
- package/dist/compareCandidateVersions.js.map +1 -0
- package/dist/compareCandidateVersions.test.js +32 -0
- package/dist/compareCandidateVersions.test.js.map +1 -0
- package/dist/createPackageFilter.js +81 -0
- package/dist/createPackageFilter.js.map +1 -0
- package/dist/createPackageFilter.test.js +115 -0
- package/dist/createPackageFilter.test.js.map +1 -0
- package/dist/definitions/buildIdentifiedFixesMap.d.ts +4 -0
- package/dist/definitions/buildIdentifiedFixesMap.d.ts.map +1 -0
- package/dist/definitions/buildLockstepClusters.d.ts +8 -0
- package/dist/definitions/buildLockstepClusters.d.ts.map +1 -0
- package/dist/definitions/buildLockstepClusters.test.d.ts +2 -0
- package/dist/definitions/buildLockstepClusters.test.d.ts.map +1 -0
- package/dist/definitions/clusterLabel.d.ts +8 -0
- package/dist/definitions/clusterLabel.d.ts.map +1 -0
- package/dist/definitions/clusterLabel.test.d.ts +2 -0
- package/dist/definitions/clusterLabel.test.d.ts.map +1 -0
- package/dist/definitions/compareCandidateVersions.d.ts +10 -0
- package/dist/definitions/compareCandidateVersions.d.ts.map +1 -0
- package/dist/definitions/compareCandidateVersions.test.d.ts +2 -0
- package/dist/definitions/compareCandidateVersions.test.d.ts.map +1 -0
- package/dist/definitions/createPackageFilter.d.ts +45 -0
- package/dist/definitions/createPackageFilter.d.ts.map +1 -0
- package/dist/definitions/createPackageFilter.test.d.ts +2 -0
- package/dist/definitions/createPackageFilter.test.d.ts.map +1 -0
- package/dist/definitions/duplicateSnapshot.d.ts +8 -0
- package/dist/definitions/duplicateSnapshot.d.ts.map +1 -0
- package/dist/definitions/fileSnapshot.d.ts +7 -0
- package/dist/definitions/fileSnapshot.d.ts.map +1 -0
- package/dist/definitions/findProjectRoot.d.ts +27 -0
- package/dist/definitions/findProjectRoot.d.ts.map +1 -0
- package/dist/definitions/findProjectRoot.test.d.ts +2 -0
- package/dist/definitions/findProjectRoot.test.d.ts.map +1 -0
- package/dist/definitions/identifyLockstepClusterFixes.d.ts +89 -0
- package/dist/definitions/identifyLockstepClusterFixes.d.ts.map +1 -0
- package/dist/definitions/identifyLockstepClusterFixes.test.d.ts +2 -0
- package/dist/definitions/identifyLockstepClusterFixes.test.d.ts.map +1 -0
- package/dist/definitions/identifyResolutionFixes.d.ts +28 -0
- package/dist/definitions/identifyResolutionFixes.d.ts.map +1 -0
- package/dist/definitions/identifyResolutionFixes.test.d.ts +2 -0
- package/dist/definitions/identifyResolutionFixes.test.d.ts.map +1 -0
- package/dist/definitions/index.d.ts +44 -0
- package/dist/definitions/index.d.ts.map +1 -0
- package/dist/definitions/packageDependenciesUtils.d.ts +47 -0
- package/dist/definitions/packageDependenciesUtils.d.ts.map +1 -0
- package/dist/definitions/packageDependenciesUtils.test.d.ts +2 -0
- package/dist/definitions/packageDependenciesUtils.test.d.ts.map +1 -0
- package/dist/definitions/packageFilterArgs.d.ts +74 -0
- package/dist/definitions/packageFilterArgs.d.ts.map +1 -0
- package/dist/definitions/packageFilterArgs.test.d.ts +2 -0
- package/dist/definitions/packageFilterArgs.test.d.ts.map +1 -0
- package/dist/definitions/packageStyles.d.ts +14 -0
- package/dist/definitions/packageStyles.d.ts.map +1 -0
- package/dist/definitions/packageStyles.test.d.ts +2 -0
- package/dist/definitions/packageStyles.test.d.ts.map +1 -0
- package/dist/definitions/parseBinArgs.d.ts +14 -0
- package/dist/definitions/parseBinArgs.d.ts.map +1 -0
- package/dist/definitions/parseBinArgs.test.d.ts +2 -0
- package/dist/definitions/parseBinArgs.test.d.ts.map +1 -0
- package/dist/definitions/planClusterApply.d.ts +39 -0
- package/dist/definitions/planClusterApply.d.ts.map +1 -0
- package/dist/definitions/planClusterApply.test.d.ts +2 -0
- package/dist/definitions/planClusterApply.test.d.ts.map +1 -0
- package/dist/definitions/renderApplyPlan.d.ts +22 -0
- package/dist/definitions/renderApplyPlan.d.ts.map +1 -0
- package/dist/definitions/renderApplyPlan.test.d.ts +2 -0
- package/dist/definitions/renderApplyPlan.test.d.ts.map +1 -0
- package/dist/definitions/renderDedupeSummary.d.ts +22 -0
- package/dist/definitions/renderDedupeSummary.d.ts.map +1 -0
- package/dist/definitions/renderDedupeSummary.test.d.ts +2 -0
- package/dist/definitions/renderDedupeSummary.test.d.ts.map +1 -0
- package/dist/definitions/renderDuplicatesReport.d.ts +39 -0
- package/dist/definitions/renderDuplicatesReport.d.ts.map +1 -0
- package/dist/definitions/renderDuplicatesReport.test.d.ts +2 -0
- package/dist/definitions/renderDuplicatesReport.test.d.ts.map +1 -0
- package/dist/definitions/reportColors.d.ts +25 -0
- package/dist/definitions/reportColors.d.ts.map +1 -0
- package/dist/definitions/reportColors.test.d.ts +2 -0
- package/dist/definitions/reportColors.test.d.ts.map +1 -0
- package/dist/definitions/reportText.d.ts +29 -0
- package/dist/definitions/reportText.d.ts.map +1 -0
- package/dist/definitions/selectExplainedPackages.d.ts +22 -0
- package/dist/definitions/selectExplainedPackages.d.ts.map +1 -0
- package/dist/definitions/selectExplainedPackages.test.d.ts +2 -0
- package/dist/definitions/selectExplainedPackages.test.d.ts.map +1 -0
- package/dist/definitions/unconditionalOverrides.d.ts +24 -0
- package/dist/definitions/unconditionalOverrides.d.ts.map +1 -0
- package/dist/definitions/unconditionalOverrides.test.d.ts +2 -0
- package/dist/definitions/unconditionalOverrides.test.d.ts.map +1 -0
- package/dist/definitions/versionsSnapshot.d.ts +40 -0
- package/dist/definitions/versionsSnapshot.d.ts.map +1 -0
- package/dist/definitions/versionsSnapshot.test.d.ts +2 -0
- package/dist/definitions/versionsSnapshot.test.d.ts.map +1 -0
- package/dist/definitions/workspaceManifest.d.ts +21 -0
- package/dist/definitions/workspaceManifest.d.ts.map +1 -0
- package/dist/definitions/workspaceManifest.test.d.ts +2 -0
- package/dist/definitions/workspaceManifest.test.d.ts.map +1 -0
- package/dist/duplicateSnapshot.js +10 -0
- package/dist/duplicateSnapshot.js.map +1 -0
- package/dist/fileSnapshot.js +21 -0
- package/dist/fileSnapshot.js.map +1 -0
- package/dist/findProjectRoot.js +48 -0
- package/dist/findProjectRoot.js.map +1 -0
- package/dist/findProjectRoot.test.js +53 -0
- package/dist/findProjectRoot.test.js.map +1 -0
- package/dist/identifyLockstepClusterFixes.js +352 -0
- package/dist/identifyLockstepClusterFixes.js.map +1 -0
- package/dist/identifyLockstepClusterFixes.test.js +372 -0
- package/dist/identifyLockstepClusterFixes.test.js.map +1 -0
- package/dist/identifyResolutionFixes.js +102 -0
- package/dist/identifyResolutionFixes.js.map +1 -0
- package/dist/identifyResolutionFixes.test.js +70 -0
- package/dist/identifyResolutionFixes.test.js.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/packageDependenciesUtils.js +91 -0
- package/dist/packageDependenciesUtils.js.map +1 -0
- package/dist/packageDependenciesUtils.test.js +539 -0
- package/dist/packageDependenciesUtils.test.js.map +1 -0
- package/dist/packageFilterArgs.js +65 -0
- package/dist/packageFilterArgs.js.map +1 -0
- package/dist/packageFilterArgs.test.js +93 -0
- package/dist/packageFilterArgs.test.js.map +1 -0
- package/dist/packageStyles.js +33 -0
- package/dist/packageStyles.js.map +1 -0
- package/dist/packageStyles.test.js +58 -0
- package/dist/packageStyles.test.js.map +1 -0
- package/dist/parseBinArgs.js +28 -0
- package/dist/parseBinArgs.js.map +1 -0
- package/dist/parseBinArgs.test.js +62 -0
- package/dist/parseBinArgs.test.js.map +1 -0
- package/dist/planClusterApply.js +69 -0
- package/dist/planClusterApply.js.map +1 -0
- package/dist/planClusterApply.test.js +152 -0
- package/dist/planClusterApply.test.js.map +1 -0
- package/dist/renderApplyPlan.js +51 -0
- package/dist/renderApplyPlan.js.map +1 -0
- package/dist/renderApplyPlan.test.js +106 -0
- package/dist/renderApplyPlan.test.js.map +1 -0
- package/dist/renderDedupeSummary.js +38 -0
- package/dist/renderDedupeSummary.js.map +1 -0
- package/dist/renderDedupeSummary.test.js +91 -0
- package/dist/renderDedupeSummary.test.js.map +1 -0
- package/dist/renderDuplicatesReport.js +375 -0
- package/dist/renderDuplicatesReport.js.map +1 -0
- package/dist/renderDuplicatesReport.test.js +598 -0
- package/dist/renderDuplicatesReport.test.js.map +1 -0
- package/dist/reportColors.js +51 -0
- package/dist/reportColors.js.map +1 -0
- package/dist/reportColors.test.js +49 -0
- package/dist/reportColors.test.js.map +1 -0
- package/dist/reportText.js +29 -0
- package/dist/reportText.js.map +1 -0
- package/dist/selectExplainedPackages.js +42 -0
- package/dist/selectExplainedPackages.js.map +1 -0
- package/dist/selectExplainedPackages.test.js +55 -0
- package/dist/selectExplainedPackages.test.js.map +1 -0
- package/dist/unconditionalOverrides.js +36 -0
- package/dist/unconditionalOverrides.js.map +1 -0
- package/dist/unconditionalOverrides.test.js +71 -0
- package/dist/unconditionalOverrides.test.js.map +1 -0
- package/dist/versionsSnapshot.js +49 -0
- package/dist/versionsSnapshot.js.map +1 -0
- package/dist/versionsSnapshot.test.js +79 -0
- package/dist/versionsSnapshot.test.js.map +1 -0
- package/dist/workspaceManifest.js +63 -0
- package/dist/workspaceManifest.js.map +1 -0
- package/dist/workspaceManifest.test.js +159 -0
- package/dist/workspaceManifest.test.js.map +1 -0
- package/package.json +68 -0
- package/src/buildIdentifiedFixesMap.ts +22 -0
- package/src/buildLockstepClusters.test.ts +90 -0
- package/src/buildLockstepClusters.ts +155 -0
- package/src/clusterLabel.test.ts +79 -0
- package/src/clusterLabel.ts +75 -0
- package/src/compareCandidateVersions.test.ts +39 -0
- package/src/compareCandidateVersions.ts +23 -0
- package/src/createPackageFilter.test.ts +156 -0
- package/src/createPackageFilter.ts +142 -0
- package/src/duplicateSnapshot.ts +24 -0
- package/src/fileSnapshot.ts +28 -0
- package/src/findProjectRoot.test.ts +66 -0
- package/src/findProjectRoot.ts +61 -0
- package/src/identifyLockstepClusterFixes.test.ts +462 -0
- package/src/identifyLockstepClusterFixes.ts +602 -0
- package/src/identifyResolutionFixes.test.ts +133 -0
- package/src/identifyResolutionFixes.ts +183 -0
- package/src/index.ts +123 -0
- package/src/packageDependenciesUtils.test.ts +593 -0
- package/src/packageDependenciesUtils.ts +156 -0
- package/src/packageFilterArgs.test.ts +116 -0
- package/src/packageFilterArgs.ts +98 -0
- package/src/packageStyles.test.ts +77 -0
- package/src/packageStyles.ts +42 -0
- package/src/parseBinArgs.test.ts +73 -0
- package/src/parseBinArgs.ts +33 -0
- package/src/planClusterApply.test.ts +163 -0
- package/src/planClusterApply.ts +105 -0
- package/src/renderApplyPlan.test.ts +136 -0
- package/src/renderApplyPlan.ts +95 -0
- package/src/renderDedupeSummary.test.ts +121 -0
- package/src/renderDedupeSummary.ts +78 -0
- package/src/renderDuplicatesReport.test.ts +724 -0
- package/src/renderDuplicatesReport.ts +645 -0
- package/src/reportColors.test.ts +57 -0
- package/src/reportColors.ts +78 -0
- package/src/reportText.ts +58 -0
- package/src/selectExplainedPackages.test.ts +67 -0
- package/src/selectExplainedPackages.ts +79 -0
- package/src/unconditionalOverrides.test.ts +99 -0
- package/src/unconditionalOverrides.ts +60 -0
- package/src/versionsSnapshot.test.ts +146 -0
- package/src/versionsSnapshot.ts +93 -0
- package/src/workspaceManifest.test.ts +220 -0
- 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
|
+
};
|