pnpm-dedup 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 +218 -0
- package/dist/applyClusterFixes.js +252 -0
- package/dist/applyClusterFixes.js.map +1 -0
- package/dist/applyClusterFixes.test.js +474 -0
- package/dist/applyClusterFixes.test.js.map +1 -0
- package/dist/bin/pnpm-dedupe.js +35 -0
- package/dist/bin/pnpm-dedupe.js.map +1 -0
- package/dist/bin/pnpm-why-duplicate.js +23 -0
- package/dist/bin/pnpm-why-duplicate.js.map +1 -0
- package/dist/bin/pnpm-why-duplicate.test.js +57 -0
- package/dist/bin/pnpm-why-duplicate.test.js.map +1 -0
- package/dist/dedupe.js +86 -0
- package/dist/dedupe.js.map +1 -0
- package/dist/dedupeCheckConsistency.test.js +149 -0
- package/dist/dedupeCheckConsistency.test.js.map +1 -0
- package/dist/dedupeEndToEnd.test.js +121 -0
- package/dist/dedupeEndToEnd.test.js.map +1 -0
- package/dist/dedupePeers.test.js +105 -0
- package/dist/dedupePeers.test.js.map +1 -0
- package/dist/definitions/applyClusterFixes.d.ts +24 -0
- package/dist/definitions/applyClusterFixes.d.ts.map +1 -0
- package/dist/definitions/applyClusterFixes.test.d.ts +2 -0
- package/dist/definitions/applyClusterFixes.test.d.ts.map +1 -0
- package/dist/definitions/bin/pnpm-dedupe.d.ts +3 -0
- package/dist/definitions/bin/pnpm-dedupe.d.ts.map +1 -0
- package/dist/definitions/bin/pnpm-why-duplicate.d.ts +3 -0
- package/dist/definitions/bin/pnpm-why-duplicate.d.ts.map +1 -0
- package/dist/definitions/bin/pnpm-why-duplicate.test.d.ts +2 -0
- package/dist/definitions/bin/pnpm-why-duplicate.test.d.ts.map +1 -0
- package/dist/definitions/dedupe.d.ts +15 -0
- package/dist/definitions/dedupe.d.ts.map +1 -0
- package/dist/definitions/dedupeCheckConsistency.test.d.ts +2 -0
- package/dist/definitions/dedupeCheckConsistency.test.d.ts.map +1 -0
- package/dist/definitions/dedupeEndToEnd.test.d.ts +2 -0
- package/dist/definitions/dedupeEndToEnd.test.d.ts.map +1 -0
- package/dist/definitions/dedupePeers.test.d.ts +2 -0
- package/dist/definitions/dedupePeers.test.d.ts.map +1 -0
- package/dist/definitions/displayMany.d.ts +16 -0
- package/dist/definitions/displayMany.d.ts.map +1 -0
- package/dist/definitions/displayMany.test.d.ts +2 -0
- package/dist/definitions/displayMany.test.d.ts.map +1 -0
- package/dist/definitions/helpers/buildIdentifiedFixesMap.test.d.ts +2 -0
- package/dist/definitions/helpers/buildIdentifiedFixesMap.test.d.ts.map +1 -0
- package/dist/definitions/helpers/buildPnpmPackagesMap.d.ts +13 -0
- package/dist/definitions/helpers/buildPnpmPackagesMap.d.ts.map +1 -0
- package/dist/definitions/helpers/buildPnpmPackagesMap.test.d.ts +2 -0
- package/dist/definitions/helpers/buildPnpmPackagesMap.test.d.ts.map +1 -0
- package/dist/definitions/helpers/collectDependentRanges.d.ts +22 -0
- package/dist/definitions/helpers/collectDependentRanges.d.ts.map +1 -0
- package/dist/definitions/helpers/collectDependentRanges.test.d.ts +2 -0
- package/dist/definitions/helpers/collectDependentRanges.test.d.ts.map +1 -0
- package/dist/definitions/helpers/collectPnpmDependents.d.ts +8 -0
- package/dist/definitions/helpers/collectPnpmDependents.d.ts.map +1 -0
- package/dist/definitions/helpers/collectPnpmDependents.test.d.ts +2 -0
- package/dist/definitions/helpers/collectPnpmDependents.test.d.ts.map +1 -0
- package/dist/definitions/helpers/duplicateSnapshot.d.ts +3 -0
- package/dist/definitions/helpers/duplicateSnapshot.d.ts.map +1 -0
- package/dist/definitions/helpers/parsePnpmLockPackages.d.ts +44 -0
- package/dist/definitions/helpers/parsePnpmLockPackages.d.ts.map +1 -0
- package/dist/definitions/helpers/parsePnpmLockPackages.test.d.ts +2 -0
- package/dist/definitions/helpers/parsePnpmLockPackages.test.d.ts.map +1 -0
- package/dist/definitions/helpers/pnpmProtocol.d.ts +2 -0
- package/dist/definitions/helpers/pnpmProtocol.d.ts.map +1 -0
- package/dist/definitions/helpers/pnpmVersion.d.ts +11 -0
- package/dist/definitions/helpers/pnpmVersion.d.ts.map +1 -0
- package/dist/definitions/helpers/pnpmWorkspaceYaml.d.ts +23 -0
- package/dist/definitions/helpers/pnpmWorkspaceYaml.d.ts.map +1 -0
- package/dist/definitions/helpers/pnpmWorkspaceYaml.test.d.ts +2 -0
- package/dist/definitions/helpers/pnpmWorkspaceYaml.test.d.ts.map +1 -0
- package/dist/definitions/helpers/projectDir.d.ts +3 -0
- package/dist/definitions/helpers/projectDir.d.ts.map +1 -0
- package/dist/definitions/helpers/readInstalledManifest.d.ts +32 -0
- package/dist/definitions/helpers/readInstalledManifest.d.ts.map +1 -0
- package/dist/definitions/helpers/readInstalledManifest.test.d.ts +2 -0
- package/dist/definitions/helpers/readInstalledManifest.test.d.ts.map +1 -0
- package/dist/definitions/helpers/runPnpm.d.ts +11 -0
- package/dist/definitions/helpers/runPnpm.d.ts.map +1 -0
- package/dist/definitions/helpers/tempProjects.d.ts +8 -0
- package/dist/definitions/helpers/tempProjects.d.ts.map +1 -0
- package/dist/definitions/helpers/toLockstepGraph.d.ts +12 -0
- package/dist/definitions/helpers/toLockstepGraph.d.ts.map +1 -0
- package/dist/definitions/helpers/toLockstepGraph.test.d.ts +2 -0
- package/dist/definitions/helpers/toLockstepGraph.test.d.ts.map +1 -0
- package/dist/definitions/helpers/versionsSnapshot.d.ts +3 -0
- package/dist/definitions/helpers/versionsSnapshot.d.ts.map +1 -0
- package/dist/definitions/identifyClusterFixes.d.ts +7 -0
- package/dist/definitions/identifyClusterFixes.d.ts.map +1 -0
- package/dist/definitions/identifyClusterFixes.test.d.ts +2 -0
- package/dist/definitions/identifyClusterFixes.test.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 +25 -0
- package/dist/definitions/index.d.ts.map +1 -0
- package/dist/definitions/pnpmLockTypes.d.ts +42 -0
- package/dist/definitions/pnpmLockTypes.d.ts.map +1 -0
- package/dist/definitions/readPnpmLock.d.ts +3 -0
- package/dist/definitions/readPnpmLock.d.ts.map +1 -0
- package/dist/displayMany.js +56 -0
- package/dist/displayMany.js.map +1 -0
- package/dist/displayMany.test.js +146 -0
- package/dist/displayMany.test.js.map +1 -0
- package/dist/helpers/buildIdentifiedFixesMap.test.js +27 -0
- package/dist/helpers/buildIdentifiedFixesMap.test.js.map +1 -0
- package/dist/helpers/buildPnpmPackagesMap.js +23 -0
- package/dist/helpers/buildPnpmPackagesMap.js.map +1 -0
- package/dist/helpers/buildPnpmPackagesMap.test.js +68 -0
- package/dist/helpers/buildPnpmPackagesMap.test.js.map +1 -0
- package/dist/helpers/collectDependentRanges.js +118 -0
- package/dist/helpers/collectDependentRanges.js.map +1 -0
- package/dist/helpers/collectDependentRanges.test.js +211 -0
- package/dist/helpers/collectDependentRanges.test.js.map +1 -0
- package/dist/helpers/collectPnpmDependents.js +55 -0
- package/dist/helpers/collectPnpmDependents.js.map +1 -0
- package/dist/helpers/collectPnpmDependents.test.js +78 -0
- package/dist/helpers/collectPnpmDependents.test.js.map +1 -0
- package/dist/helpers/duplicateSnapshot.js +5 -0
- package/dist/helpers/duplicateSnapshot.js.map +1 -0
- package/dist/helpers/parsePnpmLockPackages.js +62 -0
- package/dist/helpers/parsePnpmLockPackages.js.map +1 -0
- package/dist/helpers/parsePnpmLockPackages.test.js +62 -0
- package/dist/helpers/parsePnpmLockPackages.test.js.map +1 -0
- package/dist/helpers/pnpmProtocol.js +2 -0
- package/dist/helpers/pnpmProtocol.js.map +1 -0
- package/dist/helpers/pnpmVersion.js +23 -0
- package/dist/helpers/pnpmVersion.js.map +1 -0
- package/dist/helpers/pnpmWorkspaceYaml.js +48 -0
- package/dist/helpers/pnpmWorkspaceYaml.js.map +1 -0
- package/dist/helpers/pnpmWorkspaceYaml.test.js +91 -0
- package/dist/helpers/pnpmWorkspaceYaml.test.js.map +1 -0
- package/dist/helpers/projectDir.js +10 -0
- package/dist/helpers/projectDir.js.map +1 -0
- package/dist/helpers/readInstalledManifest.js +139 -0
- package/dist/helpers/readInstalledManifest.js.map +1 -0
- package/dist/helpers/readInstalledManifest.test.js +83 -0
- package/dist/helpers/readInstalledManifest.test.js.map +1 -0
- package/dist/helpers/runPnpm.js +16 -0
- package/dist/helpers/runPnpm.js.map +1 -0
- package/dist/helpers/tempProjects.js +85 -0
- package/dist/helpers/tempProjects.js.map +1 -0
- package/dist/helpers/toLockstepGraph.js +34 -0
- package/dist/helpers/toLockstepGraph.js.map +1 -0
- package/dist/helpers/toLockstepGraph.test.js +81 -0
- package/dist/helpers/toLockstepGraph.test.js.map +1 -0
- package/dist/helpers/versionsSnapshot.js +5 -0
- package/dist/helpers/versionsSnapshot.js.map +1 -0
- package/dist/identifyClusterFixes.js +53 -0
- package/dist/identifyClusterFixes.js.map +1 -0
- package/dist/identifyClusterFixes.test.js +307 -0
- package/dist/identifyClusterFixes.test.js.map +1 -0
- package/dist/identifyResolutionFixes.test.js +49 -0
- package/dist/identifyResolutionFixes.test.js.map +1 -0
- package/dist/index.js +86 -0
- package/dist/index.js.map +1 -0
- package/dist/pnpmLockTypes.js +7 -0
- package/dist/pnpmLockTypes.js.map +1 -0
- package/dist/readPnpmLock.js +6 -0
- package/dist/readPnpmLock.js.map +1 -0
- package/package.json +71 -0
- package/src/applyClusterFixes.test.ts +560 -0
- package/src/applyClusterFixes.ts +466 -0
- package/src/bin/pnpm-dedupe.ts +44 -0
- package/src/bin/pnpm-why-duplicate.test.ts +79 -0
- package/src/bin/pnpm-why-duplicate.ts +34 -0
- package/src/dedupe.ts +124 -0
- package/src/dedupeCheckConsistency.test.ts +218 -0
- package/src/dedupeEndToEnd.test.ts +168 -0
- package/src/dedupePeers.test.ts +175 -0
- package/src/displayMany.test.ts +208 -0
- package/src/displayMany.ts +104 -0
- package/src/helpers/buildIdentifiedFixesMap.test.ts +33 -0
- package/src/helpers/buildPnpmPackagesMap.test.ts +119 -0
- package/src/helpers/buildPnpmPackagesMap.ts +53 -0
- package/src/helpers/collectDependentRanges.test.ts +311 -0
- package/src/helpers/collectDependentRanges.ts +185 -0
- package/src/helpers/collectPnpmDependents.test.ts +112 -0
- package/src/helpers/collectPnpmDependents.ts +79 -0
- package/src/helpers/duplicateSnapshot.ts +18 -0
- package/src/helpers/parsePnpmLockPackages.test.ts +93 -0
- package/src/helpers/parsePnpmLockPackages.ts +99 -0
- package/src/helpers/pnpmProtocol.ts +12 -0
- package/src/helpers/pnpmVersion.ts +28 -0
- package/src/helpers/pnpmWorkspaceYaml.test.ts +140 -0
- package/src/helpers/pnpmWorkspaceYaml.ts +77 -0
- package/src/helpers/projectDir.ts +14 -0
- package/src/helpers/readInstalledManifest.test.ts +124 -0
- package/src/helpers/readInstalledManifest.ts +202 -0
- package/src/helpers/runPnpm.ts +34 -0
- package/src/helpers/tempProjects.ts +94 -0
- package/src/helpers/toLockstepGraph.test.ts +98 -0
- package/src/helpers/toLockstepGraph.ts +52 -0
- package/src/helpers/versionsSnapshot.ts +9 -0
- package/src/identifyClusterFixes.test.ts +430 -0
- package/src/identifyClusterFixes.ts +114 -0
- package/src/identifyResolutionFixes.test.ts +82 -0
- package/src/index.ts +159 -0
- package/src/pnpmLockTypes.ts +44 -0
- package/src/readPnpmLock.ts +7 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
applyWorkspaceRangeEdit,
|
|
5
|
+
captureFiles,
|
|
6
|
+
createPackageFilter,
|
|
7
|
+
describeSkippedClusterFix,
|
|
8
|
+
diffDuplicates,
|
|
9
|
+
partitionUnconditionalOverrides,
|
|
10
|
+
planClusterApply,
|
|
11
|
+
renderApplyPlan,
|
|
12
|
+
restoreFiles,
|
|
13
|
+
selectClusterFixes,
|
|
14
|
+
shouldColorize,
|
|
15
|
+
} from "pm-dedup-core";
|
|
16
|
+
import type {
|
|
17
|
+
ApplyPlanFileChange,
|
|
18
|
+
ClusterFix,
|
|
19
|
+
DuplicateSnapshot,
|
|
20
|
+
FileSnapshot,
|
|
21
|
+
PackageFilterOptions,
|
|
22
|
+
PlannedManifestEdit,
|
|
23
|
+
PlannedOverride,
|
|
24
|
+
SelectedClusterFixes,
|
|
25
|
+
} from "pm-dedup-core";
|
|
26
|
+
import { buildPnpmPackagesMap } from "./helpers/buildPnpmPackagesMap.ts";
|
|
27
|
+
import { readDuplicateSnapshot } from "./helpers/duplicateSnapshot.ts";
|
|
28
|
+
import { parsePnpmLockPackages } from "./helpers/parsePnpmLockPackages.ts";
|
|
29
|
+
import {
|
|
30
|
+
convergenceOverridesMinVersion,
|
|
31
|
+
readPnpmVersion,
|
|
32
|
+
supportsConvergenceOverrides,
|
|
33
|
+
} from "./helpers/pnpmVersion.ts";
|
|
34
|
+
import { addOverrides, overrideKey } from "./helpers/pnpmWorkspaceYaml.ts";
|
|
35
|
+
import { lockPathOf } from "./helpers/projectDir.ts";
|
|
36
|
+
import { createManifestReader } from "./helpers/readInstalledManifest.ts";
|
|
37
|
+
import { runPnpm } from "./helpers/runPnpm.ts";
|
|
38
|
+
import { identifyClusterFixes } from "./identifyClusterFixes.ts";
|
|
39
|
+
import { readPnpmLock } from "./readPnpmLock.ts";
|
|
40
|
+
|
|
41
|
+
const issuesUrl = "https://github.com/christophehurpeau/pm-tools/issues";
|
|
42
|
+
|
|
43
|
+
export type ClusterApplyStatus =
|
|
44
|
+
| "applied"
|
|
45
|
+
| "dry-run"
|
|
46
|
+
| "kept-overrides"
|
|
47
|
+
| "not-supported"
|
|
48
|
+
| "nothing-to-do"
|
|
49
|
+
| "reverted";
|
|
50
|
+
|
|
51
|
+
export interface ClusterApplyOutcome {
|
|
52
|
+
status: ClusterApplyStatus;
|
|
53
|
+
before: DuplicateSnapshot;
|
|
54
|
+
after: DuplicateSnapshot;
|
|
55
|
+
// overrides the duplicates came back without: reported, never left behind
|
|
56
|
+
stickyOverrides: PlannedOverride[];
|
|
57
|
+
// how many edits the plan holds, so `--check` gates without re-planning
|
|
58
|
+
plannedChangeCount: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ApplyClusterFixesOptions {
|
|
62
|
+
projectDir: string;
|
|
63
|
+
dryRun?: boolean;
|
|
64
|
+
log?: (message?: string) => void;
|
|
65
|
+
// seams for tests: everything else runs against the real files
|
|
66
|
+
resolve?: () => number | null;
|
|
67
|
+
readFixes?: (projectDir: string) => ClusterFix[];
|
|
68
|
+
readDuplicates?: (lockPath: string) => DuplicateSnapshot;
|
|
69
|
+
pnpmVersion?: () => string | null;
|
|
70
|
+
// false writes plain overrides instead, which pnpm applies to every requester
|
|
71
|
+
// whatever range it declares
|
|
72
|
+
convergenceOverrides?: boolean;
|
|
73
|
+
// restricts which packages may be touched, for deduplicating a large lockfile
|
|
74
|
+
// a family at a time
|
|
75
|
+
filter?: PackageFilterOptions;
|
|
76
|
+
// what `pnpm dedupe` itself would still change, for the dry-run report. Only
|
|
77
|
+
// the caller runs that probe, so only it can say.
|
|
78
|
+
packageManagerResiduals?: string;
|
|
79
|
+
// decided by the caller, because a caller that passes `log` is not writing to
|
|
80
|
+
// `process.stdout` and cannot be read off it
|
|
81
|
+
color?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const defaultReadFixes = (projectDir: string): ClusterFix[] => {
|
|
85
|
+
const lock = readPnpmLock(lockPathOf(projectDir));
|
|
86
|
+
return identifyClusterFixes(
|
|
87
|
+
lock,
|
|
88
|
+
buildPnpmPackagesMap(parsePnpmLockPackages(lock)),
|
|
89
|
+
createManifestReader(projectDir),
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const manifestPathOf = (projectDir: string, importerPath: string): string =>
|
|
94
|
+
join(projectDir, importerPath === "." ? "" : importerPath, "package.json");
|
|
95
|
+
|
|
96
|
+
const describeOverride = (
|
|
97
|
+
override: PlannedOverride,
|
|
98
|
+
convergence: boolean,
|
|
99
|
+
): string =>
|
|
100
|
+
`"${overrideKey(override.packageName, convergence)}": "${override.version}"`;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Why the overrides below them had to stay. Used verbatim as the comment left in
|
|
104
|
+
* `pnpm-workspace.yaml` and as the console explanation, so the file and the run
|
|
105
|
+
* say the same thing. The packages are the entries that follow, so they are not
|
|
106
|
+
* repeated here.
|
|
107
|
+
*/
|
|
108
|
+
const stickyOverrideReason: string[] = [
|
|
109
|
+
"Added by pnpm-dedup.",
|
|
110
|
+
"Removing these makes `pnpm dedupe` resolve them back to a duplicate, so they",
|
|
111
|
+
"are what holds the deduplicated result. Keeping them is a workaround: no fix",
|
|
112
|
+
"was found that survives on its own. Please report this cluster so it can be",
|
|
113
|
+
`handled without a standing override: ${issuesUrl}`,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
interface ApplyState {
|
|
117
|
+
duplicates: DuplicateSnapshot;
|
|
118
|
+
// one key per open range still resolving away from the version the workspace
|
|
119
|
+
// anchors its family at
|
|
120
|
+
reuses: Set<string>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const reuseKeys = (fixes: ClusterFix[]): Set<string> =>
|
|
124
|
+
new Set(
|
|
125
|
+
fixes.flatMap((fix) =>
|
|
126
|
+
fix.reuseFixes.map(
|
|
127
|
+
(reuse) => `${reuse.requesterName}>${reuse.packageName}@${reuse.to}`,
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
export const applyClusterFixes = ({
|
|
133
|
+
projectDir,
|
|
134
|
+
dryRun = false,
|
|
135
|
+
log = console.log,
|
|
136
|
+
// `pnpm dedupe`, not `pnpm install`: an override is applied by a read-package
|
|
137
|
+
// hook that only runs during a real resolution, and an incremental install
|
|
138
|
+
// reports "Already up to date" without ever re-reading a manifest. `dedupe`
|
|
139
|
+
// re-resolves, and is also what runs right after this, so it is the only
|
|
140
|
+
// honest thing to verify against.
|
|
141
|
+
resolve = () => runPnpm(["dedupe"], { cwd: projectDir }).status,
|
|
142
|
+
readFixes = defaultReadFixes,
|
|
143
|
+
readDuplicates = readDuplicateSnapshot,
|
|
144
|
+
pnpmVersion = () => readPnpmVersion(),
|
|
145
|
+
convergenceOverrides = true,
|
|
146
|
+
filter,
|
|
147
|
+
packageManagerResiduals,
|
|
148
|
+
color = shouldColorize(),
|
|
149
|
+
}: ApplyClusterFixesOptions): ClusterApplyOutcome => {
|
|
150
|
+
const packageFilter = createPackageFilter(filter);
|
|
151
|
+
const readSelectedFixes = (dir: string): SelectedClusterFixes =>
|
|
152
|
+
selectClusterFixes(readFixes(dir), packageFilter);
|
|
153
|
+
const lockPath = lockPathOf(projectDir);
|
|
154
|
+
const workspaceYamlPath = join(projectDir, "pnpm-workspace.yaml");
|
|
155
|
+
|
|
156
|
+
const before = readDuplicates(lockPath);
|
|
157
|
+
const unchanged = (
|
|
158
|
+
status: ClusterApplyStatus,
|
|
159
|
+
plannedChangeCount = 0,
|
|
160
|
+
): ClusterApplyOutcome => ({
|
|
161
|
+
status,
|
|
162
|
+
before,
|
|
163
|
+
after: before,
|
|
164
|
+
stickyOverrides: [],
|
|
165
|
+
plannedChangeCount,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const { selected: fixes, skipped: filteredOut } =
|
|
169
|
+
readSelectedFixes(projectDir);
|
|
170
|
+
const plan = planClusterApply(fixes);
|
|
171
|
+
|
|
172
|
+
const skipped = [
|
|
173
|
+
...filteredOut.map(describeSkippedClusterFix),
|
|
174
|
+
...plan.unresolvableChanges.map(
|
|
175
|
+
(unresolvable) => `${unresolvable}: no workspace file recorded for it`,
|
|
176
|
+
),
|
|
177
|
+
...plan.conflicts.map(
|
|
178
|
+
(conflict) =>
|
|
179
|
+
`${conflict.packageName}: keeping ${conflict.kept}, ignoring ${conflict.dropped} asked by another cluster`,
|
|
180
|
+
),
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
// a plain override applies to every requester of the package, so one a
|
|
184
|
+
// third-party range rejects would force that dependent too
|
|
185
|
+
const plannedOverrides = convergenceOverrides
|
|
186
|
+
? plan.overrides
|
|
187
|
+
: (() => {
|
|
188
|
+
const { safe, rejected } = partitionUnconditionalOverrides(
|
|
189
|
+
fixes,
|
|
190
|
+
plan.overrides,
|
|
191
|
+
);
|
|
192
|
+
for (const { override, rejectedBy } of rejected) {
|
|
193
|
+
for (const constraint of rejectedBy) {
|
|
194
|
+
skipped.push(
|
|
195
|
+
`override ${describeOverride(override, false)}: ${constraint.requesterName ?? "workspace"} requires "${constraint.range}", and a plain override would force it too`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return safe;
|
|
200
|
+
})();
|
|
201
|
+
|
|
202
|
+
const changeCount = plan.manifestEdits.length + plannedOverrides.length;
|
|
203
|
+
|
|
204
|
+
const relativeManifestPath = (importerPath: string): string =>
|
|
205
|
+
relative(projectDir, manifestPathOf(projectDir, importerPath));
|
|
206
|
+
|
|
207
|
+
const planFileChanges = (): ApplyPlanFileChange[] => [
|
|
208
|
+
...[
|
|
209
|
+
...new Set(
|
|
210
|
+
plan.manifestEdits.map((edit) =>
|
|
211
|
+
relativeManifestPath(edit.importerPath),
|
|
212
|
+
),
|
|
213
|
+
),
|
|
214
|
+
].map((path) => ({
|
|
215
|
+
path,
|
|
216
|
+
changes: plan.manifestEdits
|
|
217
|
+
.filter((edit) => relativeManifestPath(edit.importerPath) === path)
|
|
218
|
+
.map(
|
|
219
|
+
(edit) =>
|
|
220
|
+
`"${edit.packageName}": "${edit.range}" -> "${edit.to}" (${edit.depType})`,
|
|
221
|
+
),
|
|
222
|
+
})),
|
|
223
|
+
{
|
|
224
|
+
path: relative(projectDir, workspaceYamlPath),
|
|
225
|
+
transient: "removed once the result is verified",
|
|
226
|
+
changes: plannedOverrides.map(
|
|
227
|
+
(override) =>
|
|
228
|
+
`${describeOverride(override, convergenceOverrides)} (${override.reason})`,
|
|
229
|
+
),
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
// A dry run prints the plan through the shared renderer and stops. Anything
|
|
234
|
+
// that rules the plan out has to be decided before that, or the report would
|
|
235
|
+
// promise edits pnpm cannot make.
|
|
236
|
+
const reportPlan = (applicable: boolean): ClusterApplyOutcome => {
|
|
237
|
+
renderApplyPlan({
|
|
238
|
+
fileChanges: applicable ? planFileChanges() : [],
|
|
239
|
+
skipped,
|
|
240
|
+
packageManagerResiduals,
|
|
241
|
+
dedupeCommand: "pnpm-dedupe",
|
|
242
|
+
log,
|
|
243
|
+
color,
|
|
244
|
+
});
|
|
245
|
+
return unchanged("dry-run", applicable ? changeCount : 0);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (!dryRun) {
|
|
249
|
+
for (const entry of skipped) {
|
|
250
|
+
log(` Skipped ${entry}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (changeCount === 0) {
|
|
255
|
+
return dryRun ? reportPlan(false) : unchanged("nothing-to-do");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (convergenceOverrides) {
|
|
259
|
+
const version = pnpmVersion();
|
|
260
|
+
if (version === null || !supportsConvergenceOverrides(version)) {
|
|
261
|
+
log(
|
|
262
|
+
`Cluster fixes need pnpm >= ${convergenceOverridesMinVersion} (convergence overrides); found ${version ?? "no pnpm"}. Skipping them.`,
|
|
263
|
+
);
|
|
264
|
+
// nothing can be applied, so a `--check` run has nothing to fail on
|
|
265
|
+
return dryRun ? reportPlan(false) : unchanged("not-supported");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (dryRun) {
|
|
270
|
+
return reportPlan(true);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const touchedPaths = [
|
|
274
|
+
...new Set(
|
|
275
|
+
plan.manifestEdits.map((edit) =>
|
|
276
|
+
manifestPathOf(projectDir, edit.importerPath),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
workspaceYamlPath,
|
|
280
|
+
lockPath,
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
const originalSnapshot = captureFiles(touchedPaths);
|
|
284
|
+
|
|
285
|
+
const revertTo = (snapshot: FileSnapshot[]): void => {
|
|
286
|
+
restoreFiles(snapshot);
|
|
287
|
+
resolve();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const editManifests = (edits: PlannedManifestEdit[]): boolean =>
|
|
291
|
+
edits.every((edit) => {
|
|
292
|
+
const path = manifestPathOf(projectDir, edit.importerPath);
|
|
293
|
+
const updated = applyWorkspaceRangeEdit(readFileSync(path, "utf8"), edit);
|
|
294
|
+
if (updated === undefined) {
|
|
295
|
+
log(
|
|
296
|
+
` ${path} no longer declares "${edit.packageName}": "${edit.range}" — the lockfile is out of date`,
|
|
297
|
+
);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
log(
|
|
301
|
+
` ${path}: "${edit.packageName}" ${edit.range} -> ${edit.to} in ${edit.depType}`,
|
|
302
|
+
);
|
|
303
|
+
writeFileSync(path, updated);
|
|
304
|
+
return true;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// A reuse fix removes no duplicate — both copies keep their dependents — so
|
|
308
|
+
// the duplicate set cannot tell whether it took. The detector can: the fix is
|
|
309
|
+
// gone from its output once the edge points at the anchored version.
|
|
310
|
+
const readState = (): ApplyState => ({
|
|
311
|
+
duplicates: readDuplicates(lockPath),
|
|
312
|
+
reuses: reuseKeys(readSelectedFixes(projectDir).selected),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// A step keeps its edits as long as it broke nothing: a widened range often
|
|
316
|
+
// deduplicates nothing on its own and only makes the overrides applicable.
|
|
317
|
+
const resolveAndCheck = (label: string): ApplyState | null => {
|
|
318
|
+
if (resolve() !== 0) {
|
|
319
|
+
log(` \`pnpm dedupe\` failed after ${label} — reverting`);
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const state = readState();
|
|
323
|
+
const { added } = diffDuplicates(before, state.duplicates);
|
|
324
|
+
if (added.length > 0) {
|
|
325
|
+
log(` ${label} introduced ${added.length} new duplicate(s) — reverting`);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return state;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
if (plan.manifestEdits.length > 0) {
|
|
332
|
+
log("Editing workspace ranges:");
|
|
333
|
+
if (!editManifests(plan.manifestEdits)) {
|
|
334
|
+
revertTo(originalSnapshot);
|
|
335
|
+
return unchanged("reverted", changeCount);
|
|
336
|
+
}
|
|
337
|
+
if (resolveAndCheck("the workspace range edits") === null) {
|
|
338
|
+
revertTo(originalSnapshot);
|
|
339
|
+
return unchanged("reverted", changeCount);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const withoutOverridesSnapshot = captureFiles(touchedPaths);
|
|
344
|
+
const workspaceYamlBefore = withoutOverridesSnapshot.find(
|
|
345
|
+
(snapshot) => snapshot.path === workspaceYamlPath,
|
|
346
|
+
)!;
|
|
347
|
+
|
|
348
|
+
const remaining = readState();
|
|
349
|
+
// An override is still worth writing while its package is duplicated, or
|
|
350
|
+
// while the edge it repoints is still resolving elsewhere.
|
|
351
|
+
const outstanding = plannedOverrides.filter(
|
|
352
|
+
(override) =>
|
|
353
|
+
[...remaining.duplicates].some((resolution) =>
|
|
354
|
+
resolution.startsWith(`${override.packageName}@`),
|
|
355
|
+
) ||
|
|
356
|
+
[...remaining.reuses].some((key) =>
|
|
357
|
+
key.endsWith(`>${override.packageName}@${override.version}`),
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
if (outstanding.length === 0) {
|
|
362
|
+
return {
|
|
363
|
+
status: "applied",
|
|
364
|
+
before,
|
|
365
|
+
after: remaining.duplicates,
|
|
366
|
+
stickyOverrides: [],
|
|
367
|
+
plannedChangeCount: changeCount,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
log(
|
|
372
|
+
`Adding ${convergenceOverrides ? "convergence" : "plain"} overrides to pnpm-workspace.yaml:`,
|
|
373
|
+
);
|
|
374
|
+
for (const override of outstanding) {
|
|
375
|
+
log(
|
|
376
|
+
` ${describeOverride(override, convergenceOverrides)} (${override.reason})`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
writeFileSync(
|
|
380
|
+
workspaceYamlPath,
|
|
381
|
+
addOverrides(
|
|
382
|
+
workspaceYamlBefore.content,
|
|
383
|
+
new Map(
|
|
384
|
+
outstanding.map((override) => [override.packageName, override.version]),
|
|
385
|
+
),
|
|
386
|
+
{ convergence: convergenceOverrides },
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const withOverrides = resolveAndCheck("the overrides");
|
|
391
|
+
if (withOverrides === null) {
|
|
392
|
+
revertTo(withoutOverridesSnapshot);
|
|
393
|
+
return unchanged("reverted", changeCount);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Overrides are scaffolding first: if pnpm holds the result without them they
|
|
397
|
+
// are removed, and if it does not they go back with the reason recorded.
|
|
398
|
+
log("Removing the overrides and re-resolving to check the result holds:");
|
|
399
|
+
restoreFiles([workspaceYamlBefore]);
|
|
400
|
+
|
|
401
|
+
if (resolve() !== 0) {
|
|
402
|
+
log(" `pnpm dedupe` failed without the overrides — reverting");
|
|
403
|
+
revertTo(withoutOverridesSnapshot);
|
|
404
|
+
return unchanged("reverted", changeCount);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const after = readState();
|
|
408
|
+
const returnedDuplicates = diffDuplicates(
|
|
409
|
+
withOverrides.duplicates,
|
|
410
|
+
after.duplicates,
|
|
411
|
+
).added;
|
|
412
|
+
const returnedReuses = [...after.reuses].filter(
|
|
413
|
+
(key) => !withOverrides.reuses.has(key),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
if (returnedDuplicates.length > 0 || returnedReuses.length > 0) {
|
|
417
|
+
writeFileSync(
|
|
418
|
+
workspaceYamlPath,
|
|
419
|
+
addOverrides(
|
|
420
|
+
workspaceYamlBefore.content,
|
|
421
|
+
new Map(
|
|
422
|
+
outstanding.map((override) => [
|
|
423
|
+
override.packageName,
|
|
424
|
+
override.version,
|
|
425
|
+
]),
|
|
426
|
+
),
|
|
427
|
+
{
|
|
428
|
+
convergence: convergenceOverrides,
|
|
429
|
+
comment: stickyOverrideReason.join("\n"),
|
|
430
|
+
},
|
|
431
|
+
),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
if (resolve() !== 0) {
|
|
435
|
+
log(" `pnpm dedupe` failed with the overrides back — reverting");
|
|
436
|
+
revertTo(withoutOverridesSnapshot);
|
|
437
|
+
return unchanged("reverted", changeCount);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
log(
|
|
441
|
+
` Kept in ${workspaceYamlPath}, with the same explanation as a comment:`,
|
|
442
|
+
);
|
|
443
|
+
for (const override of outstanding) {
|
|
444
|
+
log(` ${describeOverride(override, convergenceOverrides)}`);
|
|
445
|
+
}
|
|
446
|
+
for (const line of stickyOverrideReason) {
|
|
447
|
+
log(` ${line}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
status: "kept-overrides",
|
|
452
|
+
before,
|
|
453
|
+
after: readDuplicates(lockPath),
|
|
454
|
+
stickyOverrides: outstanding,
|
|
455
|
+
plannedChangeCount: changeCount,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
status: "applied",
|
|
461
|
+
before,
|
|
462
|
+
after: after.duplicates,
|
|
463
|
+
stickyOverrides: [],
|
|
464
|
+
plannedChangeCount: changeCount,
|
|
465
|
+
};
|
|
466
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
packageFilterParseArgsOptions,
|
|
5
|
+
packageFilterUsage,
|
|
6
|
+
parseBinArgs,
|
|
7
|
+
toPackageFilterOptions,
|
|
8
|
+
} from "pm-dedup-core";
|
|
9
|
+
import { dedupe } from "../dedupe.ts";
|
|
10
|
+
import type { DedupeMode } from "../dedupe.ts";
|
|
11
|
+
|
|
12
|
+
const usage = `Usage: pnpm-dedupe [options]
|
|
13
|
+
|
|
14
|
+
--check print the plan, change nothing, exit 1 if anything would change
|
|
15
|
+
-n, --dry-run print the plan, change nothing
|
|
16
|
+
--no-convergence-overrides write plain overrides instead of convergence ones
|
|
17
|
+
|
|
18
|
+
${packageFilterUsage}`;
|
|
19
|
+
|
|
20
|
+
const parsed = parseBinArgs(usage, {
|
|
21
|
+
args: process.argv.slice(2),
|
|
22
|
+
options: {
|
|
23
|
+
check: { type: "boolean" },
|
|
24
|
+
"dry-run": { type: "boolean", short: "n" },
|
|
25
|
+
"no-convergence-overrides": { type: "boolean" },
|
|
26
|
+
...packageFilterParseArgsOptions,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (parsed !== null) {
|
|
31
|
+
const { values } = parsed;
|
|
32
|
+
|
|
33
|
+
const mode = ((): DedupeMode => {
|
|
34
|
+
if (values.check) return "check";
|
|
35
|
+
if (values["dry-run"]) return "dry-run";
|
|
36
|
+
return "apply";
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
dedupe({
|
|
40
|
+
mode,
|
|
41
|
+
convergenceOverrides: !values["no-convergence-overrides"],
|
|
42
|
+
filter: toPackageFilterOptions(values),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { cpSync, mkdirSync, realpathSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createTempProjects } from "../helpers/tempProjects.ts";
|
|
7
|
+
|
|
8
|
+
// The bins run from `src` under the test runtime; the `bin` field points at the
|
|
9
|
+
// built copy in `dist/bin`, which the end-to-end suite covers.
|
|
10
|
+
const binPath = (name: string): string =>
|
|
11
|
+
fileURLToPath(new URL(`./${name}.ts`, import.meta.url));
|
|
12
|
+
|
|
13
|
+
const fixtureDir = (name: string): string =>
|
|
14
|
+
fileURLToPath(new URL(`../../test/fixtures/${name}`, import.meta.url));
|
|
15
|
+
|
|
16
|
+
const projects = createTempProjects("pnpm-dedup-bin-");
|
|
17
|
+
|
|
18
|
+
afterEach(projects.cleanup);
|
|
19
|
+
|
|
20
|
+
interface Run {
|
|
21
|
+
status: number | null;
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const runBin = (name: string, cwd: string, args: string[]): Run => {
|
|
27
|
+
const result = spawnSync(process.execPath, [binPath(name), ...args], {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
timeout: 60_000,
|
|
31
|
+
});
|
|
32
|
+
if (result.error) throw result.error;
|
|
33
|
+
return {
|
|
34
|
+
status: result.status,
|
|
35
|
+
stdout: result.stdout,
|
|
36
|
+
stderr: result.stderr,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const project = (fixture: string): string => {
|
|
41
|
+
const dir = projects.create();
|
|
42
|
+
cpSync(fixtureDir(fixture), dir, { recursive: true });
|
|
43
|
+
return dir;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// A spawned bin reads its own cwd back resolved — the temp root is reached
|
|
47
|
+
// through a symlink on macOS — so the paths it prints are the physical ones.
|
|
48
|
+
const lockNotice = (dir: string): string =>
|
|
49
|
+
`using ${join(realpathSync(dir), "pnpm-lock.yaml")}`;
|
|
50
|
+
|
|
51
|
+
describe("pnpm-why-duplicate", () => {
|
|
52
|
+
describe("run from a subdirectory", () => {
|
|
53
|
+
it("reports on the project the lockfile is in", () => {
|
|
54
|
+
const dir = project("duplicated-printable-shell-command");
|
|
55
|
+
const nested = join(dir, "packages", "app", "src");
|
|
56
|
+
mkdirSync(nested, { recursive: true });
|
|
57
|
+
|
|
58
|
+
const fromRoot = runBin("pnpm-why-duplicate", dir, ["--details"]);
|
|
59
|
+
const fromNested = runBin("pnpm-why-duplicate", nested, ["--details"]);
|
|
60
|
+
|
|
61
|
+
expect(fromNested.status).toBe(0);
|
|
62
|
+
expect(fromNested.stdout).toBe(fromRoot.stdout);
|
|
63
|
+
expect(fromNested.stdout).toContain("printable-shell-command");
|
|
64
|
+
expect(fromNested.stderr.trim()).toBe(lockNotice(dir));
|
|
65
|
+
expect(fromRoot.stderr).toBe("");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("names the lockfile it could not find and exits 1", () => {
|
|
69
|
+
const dir = projects.create();
|
|
70
|
+
|
|
71
|
+
const result = runBin("pnpm-why-duplicate", dir, []);
|
|
72
|
+
|
|
73
|
+
expect(result.status).toBe(1);
|
|
74
|
+
expect(result.stderr.trim()).toBe(
|
|
75
|
+
`No pnpm-lock.yaml found in ${realpathSync(dir)} or any parent directory`,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
parseBinArgs,
|
|
5
|
+
toWhyDuplicateRequest,
|
|
6
|
+
whyDuplicateParseArgsOptions,
|
|
7
|
+
whyDuplicateUsage,
|
|
8
|
+
} from "pm-dedup-core";
|
|
9
|
+
import { listDuplicates, whyDuplicate } from "../index.ts";
|
|
10
|
+
|
|
11
|
+
const usage = `Usage: pnpm-why-duplicate [package] [options]
|
|
12
|
+
|
|
13
|
+
[package] a name or a glob; the short form of --packages
|
|
14
|
+
${whyDuplicateUsage}`;
|
|
15
|
+
|
|
16
|
+
const parsed = parseBinArgs(usage, {
|
|
17
|
+
args: process.argv.slice(2),
|
|
18
|
+
options: whyDuplicateParseArgsOptions,
|
|
19
|
+
allowPositionals: true,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (parsed !== null) {
|
|
23
|
+
const { values, positionals } = parsed;
|
|
24
|
+
const { filter, explains, all, details } = toWhyDuplicateRequest(
|
|
25
|
+
values,
|
|
26
|
+
positionals,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (explains) {
|
|
30
|
+
whyDuplicate({ filter, all, details });
|
|
31
|
+
} else {
|
|
32
|
+
listDuplicates({ filter, details });
|
|
33
|
+
}
|
|
34
|
+
}
|