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,645 @@
|
|
|
1
|
+
import semver from "semver";
|
|
2
|
+
import { clusterLabel } from "./clusterLabel.ts";
|
|
3
|
+
import type {
|
|
4
|
+
ClusterExternalConstraint,
|
|
5
|
+
ClusterFix,
|
|
6
|
+
} from "./identifyLockstepClusterFixes.ts";
|
|
7
|
+
import { stylePackageName, stylePackageReference } from "./packageStyles.ts";
|
|
8
|
+
import { createColorize, shouldColorize } from "./reportColors.ts";
|
|
9
|
+
import type { Colorize } from "./reportColors.ts";
|
|
10
|
+
import { columnWidth, listText, padTo, plural } from "./reportText.ts";
|
|
11
|
+
|
|
12
|
+
export interface DuplicateResolutionView {
|
|
13
|
+
resolution: string;
|
|
14
|
+
// the npm version this resolution carries, when it has one. Dependents are
|
|
15
|
+
// grouped under it and merges are named by it; a resolution without one — a
|
|
16
|
+
// git url, a tarball, a `file:` — stands on its own reference instead.
|
|
17
|
+
version?: string;
|
|
18
|
+
installations: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DuplicateDependentView {
|
|
22
|
+
requester: string;
|
|
23
|
+
range: string;
|
|
24
|
+
// version this requester actually got, which is what files it under one of
|
|
25
|
+
// the resolutions. A lockfile that only stores resolved versions leaves it
|
|
26
|
+
// undefined, and the requester is then listed as unattributed rather than
|
|
27
|
+
// silently dropped.
|
|
28
|
+
resolvedVersion?: string;
|
|
29
|
+
// the resolution this requester got, for the copies that have no npm version
|
|
30
|
+
// to be filed under — a `patch:` install, a tarball, a git url. Authoritative
|
|
31
|
+
// when set: such a requester is not holding the sole npm version either.
|
|
32
|
+
resolvedResolution?: string;
|
|
33
|
+
// the range is declared as a peer: it constrains the version without the
|
|
34
|
+
// requester holding a copy of its own. Marked after the range rather than
|
|
35
|
+
// appended to the requester, which would push long names past the column
|
|
36
|
+
// budget and leave the group ragged.
|
|
37
|
+
peer?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// How a duplicate would collapse: the resolutions that go away, and the one
|
|
41
|
+
// they merge into.
|
|
42
|
+
export interface DuplicateDedupeView {
|
|
43
|
+
from: string[];
|
|
44
|
+
to: string;
|
|
45
|
+
// spelled out when the target is not the highest of them: a downgrade is a
|
|
46
|
+
// decision, not a detail
|
|
47
|
+
direction?: ClusterFix["direction"];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DuplicatePackageView {
|
|
51
|
+
packageName: string;
|
|
52
|
+
resolutions: DuplicateResolutionView[];
|
|
53
|
+
dependents: DuplicateDependentView[];
|
|
54
|
+
dedupe: DuplicateDedupeView[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// `duplicates` for the report that dropped whatever resolves once, `matches`
|
|
58
|
+
// for the one that kept it
|
|
59
|
+
export type DuplicatesReportTitle = "duplicates" | "matches";
|
|
60
|
+
|
|
61
|
+
export interface DuplicatesReportOptions {
|
|
62
|
+
title: DuplicatesReportTitle;
|
|
63
|
+
packages: DuplicatePackageView[];
|
|
64
|
+
// headline replacing the count line, for a report whose packages are not
|
|
65
|
+
// duplicates and would otherwise read as "No duplicates found" and nothing
|
|
66
|
+
notice?: string;
|
|
67
|
+
// every package in the lockfile, not just the ones listed here
|
|
68
|
+
totalDependencies: number;
|
|
69
|
+
clusterFixes?: ClusterFix[];
|
|
70
|
+
dedupeCommand: string;
|
|
71
|
+
// the `why-duplicate` command, for the line that points at `--details`
|
|
72
|
+
whyCommand?: string;
|
|
73
|
+
// one line per package by default; the whole tree of dependents when set
|
|
74
|
+
details?: boolean;
|
|
75
|
+
color?: boolean;
|
|
76
|
+
log?: (message?: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const clusterExplanation = [
|
|
80
|
+
"A lockstep cluster is a family of packages published together: each member",
|
|
81
|
+
"pins its siblings at its own version, so a duplicate inside the family only",
|
|
82
|
+
"disappears when the whole family moves to a single version.",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const unattributedLabel = "(resolved version unknown)";
|
|
86
|
+
|
|
87
|
+
const directionLabel = (
|
|
88
|
+
direction: ClusterFix["direction"] | undefined,
|
|
89
|
+
): string => {
|
|
90
|
+
if (direction === "down") return " (downgrade)";
|
|
91
|
+
if (direction === "up") return " (upgrade)";
|
|
92
|
+
return "";
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const compareVersionsDescending = (
|
|
96
|
+
versionA: string,
|
|
97
|
+
versionB: string,
|
|
98
|
+
): number =>
|
|
99
|
+
semver.valid(versionA) !== null && semver.valid(versionB) !== null
|
|
100
|
+
? semver.rcompare(versionA, versionB)
|
|
101
|
+
: versionA.localeCompare(versionB);
|
|
102
|
+
|
|
103
|
+
interface ResolutionGroup {
|
|
104
|
+
label: string;
|
|
105
|
+
// undefined for a resolution that carries no npm version
|
|
106
|
+
version: string | undefined;
|
|
107
|
+
installations: string[];
|
|
108
|
+
dependents: DuplicateDependentView[];
|
|
109
|
+
// the version this group would collapse into, when it would
|
|
110
|
+
mergesInto: string | undefined;
|
|
111
|
+
direction: ClusterFix["direction"] | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface ClusterConvergence {
|
|
115
|
+
target: string;
|
|
116
|
+
direction: ClusterFix["direction"];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface PackageAnalysis {
|
|
120
|
+
groups: ResolutionGroup[];
|
|
121
|
+
unattributed: DuplicateDependentView[];
|
|
122
|
+
// what the package is left resolved to once the merges are applied, highest
|
|
123
|
+
// first. A cluster pass can reach a version the lockfile does not carry, so
|
|
124
|
+
// this is not a subset of the groups above.
|
|
125
|
+
after: string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const toGroups = (view: DuplicatePackageView): ResolutionGroup[] => {
|
|
129
|
+
const byLabel = new Map<string, ResolutionGroup>();
|
|
130
|
+
|
|
131
|
+
for (const { resolution, version, installations } of view.resolutions) {
|
|
132
|
+
const label = version ?? resolution;
|
|
133
|
+
const group = byLabel.get(label);
|
|
134
|
+
// several resolutions of one version — a peer context, an extra install
|
|
135
|
+
// path — are one version to the reader, and their locations are the detail
|
|
136
|
+
if (group) {
|
|
137
|
+
group.installations.push(...installations);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
byLabel.set(label, {
|
|
141
|
+
label,
|
|
142
|
+
version,
|
|
143
|
+
installations: [...installations],
|
|
144
|
+
dependents: [],
|
|
145
|
+
mergesInto: undefined,
|
|
146
|
+
direction: undefined,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return [...byLabel.values()].toSorted((groupA, groupB) => {
|
|
151
|
+
if (groupA.version === undefined || groupB.version === undefined) {
|
|
152
|
+
return groupA.version === groupB.version
|
|
153
|
+
? 0
|
|
154
|
+
: Number(groupA.version === undefined) -
|
|
155
|
+
Number(groupB.version === undefined);
|
|
156
|
+
}
|
|
157
|
+
return compareVersionsDescending(groupA.version, groupB.version);
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// A resolution with no npm version — a `patch:` install, a tarball, a git url —
|
|
162
|
+
// is its own group, labelled by the resolution string. A requester that declared
|
|
163
|
+
// it is filed there by that same string, there being no version to file it under.
|
|
164
|
+
const byResolutionOf = (
|
|
165
|
+
view: DuplicatePackageView,
|
|
166
|
+
groups: ResolutionGroup[],
|
|
167
|
+
): Map<string, ResolutionGroup> => {
|
|
168
|
+
const byLabel = new Map(groups.map((group) => [group.label, group]));
|
|
169
|
+
return new Map(
|
|
170
|
+
view.resolutions.flatMap(({ resolution, version }) => {
|
|
171
|
+
const group = byLabel.get(version ?? resolution);
|
|
172
|
+
return group ? [[resolution, group] as const] : [];
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const byVersionOf = (groups: ResolutionGroup[]): Map<string, ResolutionGroup> =>
|
|
178
|
+
new Map(
|
|
179
|
+
groups
|
|
180
|
+
.filter((group) => group.version !== undefined)
|
|
181
|
+
.map((group) => [group.version!, group]),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const applyMerges = (
|
|
185
|
+
byVersion: Map<string, ResolutionGroup>,
|
|
186
|
+
view: DuplicatePackageView,
|
|
187
|
+
cluster: ClusterConvergence | undefined,
|
|
188
|
+
): void => {
|
|
189
|
+
for (const { from, to, direction } of view.dedupe) {
|
|
190
|
+
for (const version of from) {
|
|
191
|
+
const group = byVersion.get(version);
|
|
192
|
+
if (!group) continue;
|
|
193
|
+
group.mergesInto = to;
|
|
194
|
+
group.direction = direction;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A family only converges as a whole, so a member the cluster pass covers
|
|
199
|
+
// collapses onto the family's target even though nothing about the package
|
|
200
|
+
// alone says so.
|
|
201
|
+
if (view.dedupe.length > 0 || cluster === undefined) return;
|
|
202
|
+
if (!byVersion.has(cluster.target)) return;
|
|
203
|
+
for (const group of byVersion.values()) {
|
|
204
|
+
if (group.version === cluster.target) continue;
|
|
205
|
+
group.mergesInto = cluster.target;
|
|
206
|
+
group.direction = cluster.direction;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const analysePackage = (
|
|
211
|
+
view: DuplicatePackageView,
|
|
212
|
+
cluster: ClusterConvergence | undefined,
|
|
213
|
+
): PackageAnalysis => {
|
|
214
|
+
const groups = toGroups(view);
|
|
215
|
+
const byVersion = byVersionOf(groups);
|
|
216
|
+
const byResolution = byResolutionOf(view, groups);
|
|
217
|
+
|
|
218
|
+
// A package the lockfile resolves once leaves nothing to attribute: whatever
|
|
219
|
+
// got a copy got that one. Peer ranges arrive with no resolved version of
|
|
220
|
+
// their own — the version a peer requester was handed is its parent's
|
|
221
|
+
// business — and filing twenty of them under "(resolved version unknown)"
|
|
222
|
+
// next to a single resolution reads as missing data instead of the one thing
|
|
223
|
+
// that is certain. A range the sole version does not satisfy still belongs
|
|
224
|
+
// there: an unmet peer is a warning the package manager already gave, not a
|
|
225
|
+
// second copy. Only a lone versioned group qualifies — a second resolution,
|
|
226
|
+
// even an unversioned git url or tarball, makes it a guess again.
|
|
227
|
+
const soleGroup =
|
|
228
|
+
groups.length === 1 && groups[0]!.version !== undefined
|
|
229
|
+
? groups[0]
|
|
230
|
+
: undefined;
|
|
231
|
+
|
|
232
|
+
const groupOf = (
|
|
233
|
+
dependent: DuplicateDependentView,
|
|
234
|
+
): ResolutionGroup | undefined => {
|
|
235
|
+
if (dependent.resolvedResolution !== undefined) {
|
|
236
|
+
return byResolution.get(dependent.resolvedResolution);
|
|
237
|
+
}
|
|
238
|
+
if (dependent.resolvedVersion !== undefined) {
|
|
239
|
+
return byVersion.get(dependent.resolvedVersion) ?? soleGroup;
|
|
240
|
+
}
|
|
241
|
+
return soleGroup;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const unattributed: DuplicateDependentView[] = [];
|
|
245
|
+
for (const dependent of view.dependents) {
|
|
246
|
+
const group = groupOf(dependent);
|
|
247
|
+
if (group) group.dependents.push(dependent);
|
|
248
|
+
else unattributed.push(dependent);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
applyMerges(byVersion, view, cluster);
|
|
252
|
+
|
|
253
|
+
const after = [
|
|
254
|
+
...new Set(groups.map((group) => group.mergesInto ?? group.label)),
|
|
255
|
+
].toSorted(compareVersionsDescending);
|
|
256
|
+
|
|
257
|
+
return { groups, unattributed, after };
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const merges = ({ groups }: PackageAnalysis): boolean =>
|
|
261
|
+
groups.some((group) => group.mergesInto !== undefined);
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* What the package is left with once the merges are applied. `deduped to 3
|
|
265
|
+
* versions` always means three remain — never three merged away, which is the
|
|
266
|
+
* one reading a dedupe report cannot afford to be ambiguous about.
|
|
267
|
+
*/
|
|
268
|
+
const verdictText = (color: Colorize, analysis: PackageAnalysis): string => {
|
|
269
|
+
if (!merges(analysis)) return "";
|
|
270
|
+
|
|
271
|
+
const { groups, after } = analysis;
|
|
272
|
+
const direction = groups.find(
|
|
273
|
+
(group) => group.mergesInto !== undefined,
|
|
274
|
+
)?.direction;
|
|
275
|
+
|
|
276
|
+
if (after.length === 1) {
|
|
277
|
+
return `, can be deduped to ${color("green", after[0]!)}${directionLabel(direction)}`;
|
|
278
|
+
}
|
|
279
|
+
return `, can be deduped to ${color("green", plural(after.length, "version", "versions"))}`;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const renderSummaryLine = (
|
|
283
|
+
view: DuplicatePackageView,
|
|
284
|
+
analysis: PackageAnalysis,
|
|
285
|
+
nameWidth: number,
|
|
286
|
+
color: Colorize,
|
|
287
|
+
log: (message?: string) => void,
|
|
288
|
+
): void => {
|
|
289
|
+
const { groups } = analysis;
|
|
290
|
+
const census = listText({
|
|
291
|
+
items: groups.map((group) => group.label),
|
|
292
|
+
render: (label) => color("yellow", label),
|
|
293
|
+
more: (rest) => color("dim", `, +${rest} more`),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const installations = groups.reduce(
|
|
297
|
+
(total, group) => total + group.installations.length,
|
|
298
|
+
0,
|
|
299
|
+
);
|
|
300
|
+
// with a single version the census says nothing about why the package is
|
|
301
|
+
// listed; the install paths are what make it a duplicate
|
|
302
|
+
const paths =
|
|
303
|
+
groups.length === 1 && installations > 1
|
|
304
|
+
? color("dim", ` installed at ${plural(installations, "path", "paths")}`)
|
|
305
|
+
: "";
|
|
306
|
+
|
|
307
|
+
log(
|
|
308
|
+
`- ${stylePackageName(color, view.packageName)}${padTo(nameWidth, view.packageName)} resolved to ${plural(groups.length, "version", "versions")} (${census})${paths}${verdictText(color, analysis)}`,
|
|
309
|
+
);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const dependentLine = (
|
|
313
|
+
dependent: DuplicateDependentView,
|
|
314
|
+
requesterWidth: number,
|
|
315
|
+
color: Colorize,
|
|
316
|
+
): string =>
|
|
317
|
+
` - ${stylePackageReference(color, dependent.requester)}${padTo(requesterWidth, dependent.requester)} requires ${color("yellow", `"${dependent.range}"`)}${dependent.peer ? color("dim", " (peer)") : ""}`;
|
|
318
|
+
|
|
319
|
+
const renderGroup = (
|
|
320
|
+
group: ResolutionGroup,
|
|
321
|
+
requesterWidth: number,
|
|
322
|
+
color: Colorize,
|
|
323
|
+
log: (message?: string) => void,
|
|
324
|
+
): void => {
|
|
325
|
+
const merge =
|
|
326
|
+
group.mergesInto === undefined
|
|
327
|
+
? ""
|
|
328
|
+
: ` ${color("dim", "can be deduped to")} ${color("green", group.mergesInto)}${directionLabel(group.direction)}`;
|
|
329
|
+
log(` ${stylePackageReference(color, group.label)}${merge}`);
|
|
330
|
+
|
|
331
|
+
for (const dependent of group.dependents) {
|
|
332
|
+
log(dependentLine(dependent, requesterWidth, color));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (group.dependents.length === 0) {
|
|
336
|
+
log(` ${color("dim", "(no semver range recorded for it)")}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// install paths are long, and the reader is here for the ranges: a handful
|
|
340
|
+
// locates the copy, the rest is counted
|
|
341
|
+
if (group.installations.length > 1) {
|
|
342
|
+
log(
|
|
343
|
+
` ${color("dim", "installed at:")} ${listText({
|
|
344
|
+
items: group.installations,
|
|
345
|
+
render: (location) => stylePackageReference(color, location),
|
|
346
|
+
limit: 3,
|
|
347
|
+
more: (rest) => color("dim", `, +${rest} more`),
|
|
348
|
+
})}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const renderPackageDetails = (
|
|
354
|
+
view: DuplicatePackageView,
|
|
355
|
+
analysis: PackageAnalysis,
|
|
356
|
+
clusterIndex: number | undefined,
|
|
357
|
+
color: Colorize,
|
|
358
|
+
log: (message?: string) => void,
|
|
359
|
+
): void => {
|
|
360
|
+
const { groups, unattributed } = analysis;
|
|
361
|
+
const cluster =
|
|
362
|
+
clusterIndex === undefined
|
|
363
|
+
? ""
|
|
364
|
+
: color("dim", ` (cluster ${clusterIndex})`);
|
|
365
|
+
|
|
366
|
+
log();
|
|
367
|
+
log(
|
|
368
|
+
`${stylePackageName(color, view.packageName, ["bold", "packageName"])} — ${plural(groups.length, "version", "versions")}${verdictText(color, analysis)}${cluster}`,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const requesterWidth = columnWidth(
|
|
372
|
+
view.dependents.map((dependent) => dependent.requester),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
for (const group of groups) {
|
|
376
|
+
renderGroup(group, requesterWidth, color, log);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (unattributed.length > 0) {
|
|
380
|
+
log(` ${color("dim", unattributedLabel)}`);
|
|
381
|
+
for (const dependent of unattributed) {
|
|
382
|
+
log(dependentLine(dependent, requesterWidth, color));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// a workspace importer has no npm name; it is the one requester named by role
|
|
388
|
+
const constraintName = (requesterName: string | undefined): string =>
|
|
389
|
+
requesterName ?? "workspace";
|
|
390
|
+
|
|
391
|
+
const constraintText = (
|
|
392
|
+
color: Colorize,
|
|
393
|
+
requesterName: string | undefined,
|
|
394
|
+
): string =>
|
|
395
|
+
requesterName === undefined
|
|
396
|
+
? color("dim", "workspace")
|
|
397
|
+
: stylePackageName(color, requesterName);
|
|
398
|
+
|
|
399
|
+
const nameList = (color: Colorize, names: string[]): string =>
|
|
400
|
+
names.map((name) => stylePackageName(color, name)).join(", ");
|
|
401
|
+
|
|
402
|
+
// highest version first, whatever could not be attributed to one last
|
|
403
|
+
const compareGroupLabels = (labelA: string, labelB: string): number => {
|
|
404
|
+
if (labelA === labelB) return 0;
|
|
405
|
+
if (labelA === unattributedLabel) return 1;
|
|
406
|
+
if (labelB === unattributedLabel) return -1;
|
|
407
|
+
return compareVersionsDescending(labelA, labelB);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The third-party ranges bearing on the family, filed under the version each of
|
|
412
|
+
* them resolved to — the same shape a package's own resolutions take, because it
|
|
413
|
+
* answers the same question: which requester keeps which copy alive.
|
|
414
|
+
*/
|
|
415
|
+
const renderExternalResolutions = (
|
|
416
|
+
constraints: ClusterExternalConstraint[],
|
|
417
|
+
color: Colorize,
|
|
418
|
+
log: (message?: string) => void,
|
|
419
|
+
): void => {
|
|
420
|
+
const byVersion = new Map<string, ClusterExternalConstraint[]>();
|
|
421
|
+
for (const constraint of constraints) {
|
|
422
|
+
const label = constraint.resolvedVersion ?? unattributedLabel;
|
|
423
|
+
const group = byVersion.get(label);
|
|
424
|
+
if (group) group.push(constraint);
|
|
425
|
+
else byVersion.set(label, [constraint]);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const labels = [...byVersion.keys()].toSorted(compareGroupLabels);
|
|
429
|
+
|
|
430
|
+
const width = columnWidth(
|
|
431
|
+
constraints.map((constraint) => constraintName(constraint.requesterName)),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
log(` ${color("dim", "Resolutions (external):")}`);
|
|
435
|
+
for (const label of labels) {
|
|
436
|
+
log(
|
|
437
|
+
` ${label === unattributedLabel ? color("dim", label) : stylePackageReference(color, label)}`,
|
|
438
|
+
);
|
|
439
|
+
for (const constraint of byVersion.get(label)!) {
|
|
440
|
+
log(
|
|
441
|
+
` - ${constraintText(color, constraint.requesterName)}${padTo(width, constraintName(constraint.requesterName))} requires ${stylePackageName(color, constraint.packageName)} ${color("yellow", `"${constraint.range}"`)}`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const renderCluster = (
|
|
448
|
+
fix: ClusterFix,
|
|
449
|
+
index: number,
|
|
450
|
+
color: Colorize,
|
|
451
|
+
log: (message?: string) => void,
|
|
452
|
+
): void => {
|
|
453
|
+
const fixable = fix.applicable ? fix.convergentMembers.length : 0;
|
|
454
|
+
const counts = [
|
|
455
|
+
plural(fix.members.length, "package", "packages"),
|
|
456
|
+
`${fix.duplicatedMembers.length} duplicated`,
|
|
457
|
+
`${fixable} fixable`,
|
|
458
|
+
].join(", ");
|
|
459
|
+
|
|
460
|
+
log();
|
|
461
|
+
log(
|
|
462
|
+
`cluster ${index} — ${color(["bold", "cyan"], clusterLabel(fix.members))} [${counts}]:`,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
log(` ${color("dim", "Members:")}`);
|
|
466
|
+
const width = columnWidth(fix.members);
|
|
467
|
+
for (const member of fix.members) {
|
|
468
|
+
const installed = fix.memberVersions[member];
|
|
469
|
+
const versions = color("yellow", installed?.versions.join(", ") ?? "");
|
|
470
|
+
const nonNpm =
|
|
471
|
+
installed && installed.nonNpmCount > 0
|
|
472
|
+
? color("dim", ` (+${installed.nonNpmCount} non-npm)`)
|
|
473
|
+
: "";
|
|
474
|
+
log(
|
|
475
|
+
` - ${stylePackageName(color, member)}${padTo(width, member)} ${versions}${nonNpm}`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (fix.applicable && fix.target !== null) {
|
|
480
|
+
log(
|
|
481
|
+
` ${color("dim", "Dedupe:")} ${color("green", fix.target)}${directionLabel(fix.direction)}`,
|
|
482
|
+
);
|
|
483
|
+
if (fix.driverMembers.length > 0) {
|
|
484
|
+
const followers = fix.convergentMembers.length - fix.driverMembers.length;
|
|
485
|
+
const follow =
|
|
486
|
+
followers > 0
|
|
487
|
+
? color(
|
|
488
|
+
"dim",
|
|
489
|
+
` (${plural(followers, "member follows", "members follow")})`,
|
|
490
|
+
)
|
|
491
|
+
: "";
|
|
492
|
+
log(
|
|
493
|
+
` ${color("dim", "Driven by:")} ${nameList(color, fix.driverMembers)}${follow}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
// nothing pins these, so the package manager picks their version: the
|
|
497
|
+
// fixable count above is not a guarantee for them
|
|
498
|
+
if (fix.floatingMembers.length > 0) {
|
|
499
|
+
log(
|
|
500
|
+
` ${color("dim", "Resolver picks:")} ${nameList(color, fix.floatingMembers)}`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (fix.excludedMembers.length > 0) {
|
|
506
|
+
log(` ${color("dim", "Remaining duplicates:")}`);
|
|
507
|
+
for (const excluded of fix.excludedMembers) {
|
|
508
|
+
const blockers = excluded.blockedBy
|
|
509
|
+
.map(
|
|
510
|
+
(constraint) =>
|
|
511
|
+
`${constraintText(color, constraint.requesterName)} requires ${color("yellow", `"${constraint.range}"`)}`,
|
|
512
|
+
)
|
|
513
|
+
.join(", ");
|
|
514
|
+
log(
|
|
515
|
+
` - ${stylePackageName(color, excluded.packageName, ["red"])}: ${blockers}`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (fix.reuseFixes.length > 0) {
|
|
521
|
+
log(
|
|
522
|
+
` ${color("dim", `Open ranges not reusing the pinned ${fix.anchor ?? ""}:`)}`,
|
|
523
|
+
);
|
|
524
|
+
for (const reuse of fix.reuseFixes) {
|
|
525
|
+
log(
|
|
526
|
+
` - ${constraintText(color, reuse.requesterName)} requires ${stylePackageName(color, reuse.packageName)} ${color("yellow", `"${reuse.range}"`)}, resolved ${color("yellow", reuse.from)} -> would pin ${color("green", reuse.to)}`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (fix.externalConstraints.length > 0) {
|
|
532
|
+
renderExternalResolutions(fix.externalConstraints, color, log);
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// What a family converges its members onto, for the packages whose own
|
|
537
|
+
// resolutions say nothing about it.
|
|
538
|
+
const clusterConvergences = (
|
|
539
|
+
clusterFixes: ClusterFix[],
|
|
540
|
+
): Map<string, ClusterConvergence> => {
|
|
541
|
+
const convergences = new Map<string, ClusterConvergence>();
|
|
542
|
+
for (const fix of clusterFixes) {
|
|
543
|
+
if (!fix.applicable || fix.target === null) continue;
|
|
544
|
+
for (const member of fix.convergentMembers) {
|
|
545
|
+
if (convergences.has(member)) continue;
|
|
546
|
+
convergences.set(member, {
|
|
547
|
+
target: fix.target,
|
|
548
|
+
direction: fix.direction,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return convergences;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
export const renderDuplicatesReport = ({
|
|
556
|
+
title,
|
|
557
|
+
packages,
|
|
558
|
+
notice,
|
|
559
|
+
totalDependencies,
|
|
560
|
+
clusterFixes = [],
|
|
561
|
+
dedupeCommand,
|
|
562
|
+
whyCommand,
|
|
563
|
+
details = false,
|
|
564
|
+
color: colorEnabled = shouldColorize(),
|
|
565
|
+
log = console.log,
|
|
566
|
+
}: DuplicatesReportOptions): void => {
|
|
567
|
+
const color = createColorize(colorEnabled);
|
|
568
|
+
const titleSingular = title === "duplicates" ? "duplicate" : "match";
|
|
569
|
+
|
|
570
|
+
if (notice !== undefined) {
|
|
571
|
+
log(notice);
|
|
572
|
+
} else if (packages.length === 0) {
|
|
573
|
+
log("No duplicates found");
|
|
574
|
+
} else {
|
|
575
|
+
log(`Found ${plural(packages.length, titleSingular, title)}:`);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// a cluster of one carries no family to explain: it is rendered as the
|
|
579
|
+
// package's own fix, in its own block
|
|
580
|
+
const clusters = clusterFixes.filter(
|
|
581
|
+
(fix) => fix.duplicatedMembers.length > 0 && fix.members.length > 1,
|
|
582
|
+
);
|
|
583
|
+
const clusterIndexes = new Map<string, number>();
|
|
584
|
+
clusters.forEach((fix, index) => {
|
|
585
|
+
for (const member of fix.members) {
|
|
586
|
+
if (!clusterIndexes.has(member)) clusterIndexes.set(member, index + 1);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const convergences = clusterConvergences(clusterFixes);
|
|
591
|
+
const analyses = packages.map((view) => ({
|
|
592
|
+
view,
|
|
593
|
+
analysis: analysePackage(view, convergences.get(view.packageName)),
|
|
594
|
+
}));
|
|
595
|
+
|
|
596
|
+
if (details) {
|
|
597
|
+
for (const { view, analysis } of analyses) {
|
|
598
|
+
renderPackageDetails(
|
|
599
|
+
view,
|
|
600
|
+
analysis,
|
|
601
|
+
clusterIndexes.get(view.packageName),
|
|
602
|
+
color,
|
|
603
|
+
log,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (clusters.length > 0) {
|
|
608
|
+
log();
|
|
609
|
+
log(color("dim", "Lockstep clusters:"));
|
|
610
|
+
for (const line of clusterExplanation) {
|
|
611
|
+
log(` ${color("dim", line)}`);
|
|
612
|
+
}
|
|
613
|
+
clusters.forEach((fix, index) => {
|
|
614
|
+
renderCluster(fix, index + 1, color, log);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
} else if (analyses.length > 0) {
|
|
618
|
+
const nameWidth = columnWidth(packages.map((view) => view.packageName));
|
|
619
|
+
log();
|
|
620
|
+
for (const { view, analysis } of analyses) {
|
|
621
|
+
renderSummaryLine(view, analysis, nameWidth, color, log);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const dedupable = analyses.filter(({ analysis }) => merges(analysis)).length;
|
|
626
|
+
|
|
627
|
+
log();
|
|
628
|
+
if (dedupable === 0) {
|
|
629
|
+
log(
|
|
630
|
+
`Found ${plural(totalDependencies, "dependency", "dependencies")}, ${plural(packages.length, titleSingular, title)}, 0 dedupable.`,
|
|
631
|
+
);
|
|
632
|
+
} else {
|
|
633
|
+
// Merging a package collapses its own dependency edges too, so a run can
|
|
634
|
+
// remove copies no per-package fix predicted: the count is a floor.
|
|
635
|
+
log(
|
|
636
|
+
`Found ${plural(totalDependencies, "dependency", "dependencies")}, ${plural(packages.length, titleSingular, title)}, at least ${dedupable} dedupable (deduping may remove more). Run ${color("cyan", `\`${dedupeCommand}\``)} to apply.`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!details && whyCommand !== undefined && packages.length > 0) {
|
|
641
|
+
log(
|
|
642
|
+
`Run ${color("cyan", `\`${whyCommand} --details\``)} to see every dependent.`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach, describe, it } from "bun:test";
|
|
2
|
+
import { ok, strictEqual } from "node:assert/strict";
|
|
3
|
+
import { createColorize, shouldColorize } from "./reportColors.ts";
|
|
4
|
+
|
|
5
|
+
const environment = { ...process.env };
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
process.env = { ...environment };
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("createColorize", () => {
|
|
12
|
+
it("returns the text untouched when disabled", () => {
|
|
13
|
+
strictEqual(createColorize(false)(["bold", "cyan"], "metro"), "metro");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("wraps the text when enabled", () => {
|
|
17
|
+
const wrapped = createColorize(true)("dim", "metro");
|
|
18
|
+
ok(wrapped.includes("metro"));
|
|
19
|
+
ok(wrapped.length > "metro".length);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("shouldColorize", () => {
|
|
24
|
+
it("follows the stream when nothing forces the decision", () => {
|
|
25
|
+
delete process.env.FORCE_COLOR;
|
|
26
|
+
delete process.env.NO_COLOR;
|
|
27
|
+
delete process.env.TERM;
|
|
28
|
+
strictEqual(shouldColorize({ isTTY: true }), true);
|
|
29
|
+
strictEqual(shouldColorize({ isTTY: false }), false);
|
|
30
|
+
strictEqual(shouldColorize({}), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("honours FORCE_COLOR over a piped stream", () => {
|
|
34
|
+
delete process.env.NO_COLOR;
|
|
35
|
+
process.env.FORCE_COLOR = "1";
|
|
36
|
+
strictEqual(shouldColorize({ isTTY: false }), true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("ignores FORCE_COLOR=0", () => {
|
|
40
|
+
delete process.env.NO_COLOR;
|
|
41
|
+
process.env.FORCE_COLOR = "0";
|
|
42
|
+
strictEqual(shouldColorize({ isTTY: false }), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("honours NO_COLOR over a TTY", () => {
|
|
46
|
+
delete process.env.FORCE_COLOR;
|
|
47
|
+
process.env.NO_COLOR = "1";
|
|
48
|
+
strictEqual(shouldColorize({ isTTY: true }), false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("refuses a dumb terminal", () => {
|
|
52
|
+
delete process.env.FORCE_COLOR;
|
|
53
|
+
delete process.env.NO_COLOR;
|
|
54
|
+
process.env.TERM = "dumb";
|
|
55
|
+
strictEqual(shouldColorize({ isTTY: true }), false);
|
|
56
|
+
});
|
|
57
|
+
});
|