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,18 @@
|
|
|
1
|
+
import type { DuplicateSnapshot } from "pm-dedup-core";
|
|
2
|
+
import { readPnpmLock } from "../readPnpmLock.ts";
|
|
3
|
+
import {
|
|
4
|
+
buildPnpmPackagesMap,
|
|
5
|
+
filterDuplicatesPnpmPackagesMap,
|
|
6
|
+
} from "./buildPnpmPackagesMap.ts";
|
|
7
|
+
import { parsePnpmLockPackages } from "./parsePnpmLockPackages.ts";
|
|
8
|
+
|
|
9
|
+
export const readDuplicateSnapshot = (lockPath: string): DuplicateSnapshot =>
|
|
10
|
+
new Set(
|
|
11
|
+
Object.values(
|
|
12
|
+
filterDuplicatesPnpmPackagesMap(
|
|
13
|
+
buildPnpmPackagesMap(parsePnpmLockPackages(readPnpmLock(lockPath))),
|
|
14
|
+
),
|
|
15
|
+
).flatMap((resolutions) =>
|
|
16
|
+
resolutions.map((resolution) => resolution.resolution),
|
|
17
|
+
),
|
|
18
|
+
);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it } from "bun:test";
|
|
2
|
+
import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readPnpmLock } from "../readPnpmLock.ts";
|
|
5
|
+
import {
|
|
6
|
+
parsePackageId,
|
|
7
|
+
parsePnpmLockPackages,
|
|
8
|
+
} from "./parsePnpmLockPackages.ts";
|
|
9
|
+
|
|
10
|
+
const fixturesBase = (rel: string) =>
|
|
11
|
+
fileURLToPath(new URL(rel, import.meta.url));
|
|
12
|
+
|
|
13
|
+
const readFixture = (scenario: string) =>
|
|
14
|
+
readPnpmLock(fixturesBase(`../../test/fixtures/${scenario}/pnpm-lock.yaml`));
|
|
15
|
+
|
|
16
|
+
describe("parsePackageId", () => {
|
|
17
|
+
it("parses an unscoped id", () => {
|
|
18
|
+
deepStrictEqual(parsePackageId("semver@7.7.3"), {
|
|
19
|
+
name: "semver",
|
|
20
|
+
version: "7.7.3",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("parses a scoped id", () => {
|
|
25
|
+
deepStrictEqual(parsePackageId("@babel/code-frame@7.26.2"), {
|
|
26
|
+
name: "@babel/code-frame",
|
|
27
|
+
version: "7.26.2",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("strips the peer-dependency suffix", () => {
|
|
32
|
+
deepStrictEqual(
|
|
33
|
+
parsePackageId(
|
|
34
|
+
"@typescript-eslint/utils@8.61.0(eslint@10.5.0)(typescript@6.0.3)",
|
|
35
|
+
),
|
|
36
|
+
{ name: "@typescript-eslint/utils", version: "8.61.0" },
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keeps a non-registry url as the version", () => {
|
|
41
|
+
const parsed = parsePackageId(
|
|
42
|
+
"zod@https://codeload.github.com/colinhacks/zod/tar.gz/ca42965df46b2f7e2747db29c40a26bcb32a51d5",
|
|
43
|
+
);
|
|
44
|
+
strictEqual(parsed.name, "zod");
|
|
45
|
+
strictEqual(parsed.version.startsWith("https://"), true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("parsePnpmLockPackages", () => {
|
|
50
|
+
it("parses the simple fixture (single npm package)", () => {
|
|
51
|
+
const { packages } = parsePnpmLockPackages(readFixture("simple"));
|
|
52
|
+
strictEqual(packages.size, 1);
|
|
53
|
+
const semver = packages.get("semver@7.7.3");
|
|
54
|
+
strictEqual(semver?.type, "npm");
|
|
55
|
+
strictEqual(semver.name, "semver");
|
|
56
|
+
strictEqual(semver.version, "7.7.3");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("parses both duplicated @babel/code-frame versions as npm", () => {
|
|
60
|
+
const { packages } = parsePnpmLockPackages(
|
|
61
|
+
readFixture("duplicated-babel-frame"),
|
|
62
|
+
);
|
|
63
|
+
const codeFrames = [...packages.values()].filter(
|
|
64
|
+
(pkg) => pkg.name === "@babel/code-frame",
|
|
65
|
+
);
|
|
66
|
+
strictEqual(codeFrames.length, 2);
|
|
67
|
+
ok(codeFrames.every((pkg) => pkg.type === "npm"));
|
|
68
|
+
const versions = new Set(codeFrames.map((pkg) => pkg.version));
|
|
69
|
+
ok(versions.has("7.26.2"));
|
|
70
|
+
ok(versions.size === 2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("classifies non-registry resolutions as other, registry tarballs as npm", () => {
|
|
74
|
+
const { packages } = parsePnpmLockPackages(readFixture("non-npm"));
|
|
75
|
+
const byName = new Map(
|
|
76
|
+
[...packages.values()].map((pkg) => [pkg.name, pkg]),
|
|
77
|
+
);
|
|
78
|
+
strictEqual(byName.get("react")?.type, "npm");
|
|
79
|
+
strictEqual(byName.get("zod")?.type, "other");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("groups peer-context snapshots into installations", () => {
|
|
83
|
+
const { installationsByResolution } = parsePnpmLockPackages(
|
|
84
|
+
readFixture("duplicated-typescript-eslint"),
|
|
85
|
+
);
|
|
86
|
+
const utils = installationsByResolution.get(
|
|
87
|
+
"@typescript-eslint/utils@8.61.0",
|
|
88
|
+
);
|
|
89
|
+
ok(utils);
|
|
90
|
+
ok(utils.length > 0);
|
|
91
|
+
ok(utils.every((key) => key.startsWith("@typescript-eslint/utils@8.61.0")));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { PnpmLockFile } from "../pnpmLockTypes.ts";
|
|
2
|
+
|
|
3
|
+
export interface NpmPackage {
|
|
4
|
+
type: "npm";
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
resolution: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OtherPackage {
|
|
11
|
+
type: "other";
|
|
12
|
+
name: string;
|
|
13
|
+
version: string;
|
|
14
|
+
resolution: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Package = NpmPackage | OtherPackage;
|
|
18
|
+
|
|
19
|
+
export type PnpmLockPackages = Map<string, Package>;
|
|
20
|
+
|
|
21
|
+
export interface ParsedPnpmPackages {
|
|
22
|
+
packages: PnpmLockPackages;
|
|
23
|
+
installationsByResolution: Map<string, string[]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Strip a pnpm peer-dependency suffix (everything from the first `(`).
|
|
28
|
+
*
|
|
29
|
+
* eg `@scope/name@1.2.3(eslint@9)(typescript@5)` -> `@scope/name@1.2.3`
|
|
30
|
+
*/
|
|
31
|
+
export const stripPeerSuffix = (id: string): string => {
|
|
32
|
+
const parenPos = id.indexOf("(");
|
|
33
|
+
return parenPos === -1 ? id : id.slice(0, parenPos);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse a pnpm package id (`name@version`, optionally peer-suffixed) into its
|
|
38
|
+
* name and version. Handles scoped names (`@scope/name@1.2.3`).
|
|
39
|
+
*/
|
|
40
|
+
export const parsePackageId = (
|
|
41
|
+
id: string,
|
|
42
|
+
): { name: string; version: string } => {
|
|
43
|
+
const base = stripPeerSuffix(id);
|
|
44
|
+
const atPos = base.lastIndexOf("@");
|
|
45
|
+
if (atPos <= 0) {
|
|
46
|
+
return { name: base, version: "" };
|
|
47
|
+
}
|
|
48
|
+
return { name: base.slice(0, atPos), version: base.slice(atPos + 1) };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isNpmVersion = (version: string): boolean => {
|
|
52
|
+
return /^\d/.test(version);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a snapshot dependency entry. The value is either a resolved version
|
|
57
|
+
* (`1.2.3`, possibly peer-suffixed) for which the key is the real npm name, or
|
|
58
|
+
* an aliased `realName@version` form when the key is a local alias.
|
|
59
|
+
*/
|
|
60
|
+
export const resolveSnapshotDependency = (
|
|
61
|
+
depName: string,
|
|
62
|
+
depValue: string,
|
|
63
|
+
): { name: string; version: string } => {
|
|
64
|
+
const stripped = stripPeerSuffix(depValue);
|
|
65
|
+
if (isNpmVersion(stripped)) {
|
|
66
|
+
return { name: depName, version: stripped };
|
|
67
|
+
}
|
|
68
|
+
const { name, version } = parsePackageId(stripped);
|
|
69
|
+
return { name: name || depName, version };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const parsePnpmLockPackages = (
|
|
73
|
+
lock: PnpmLockFile,
|
|
74
|
+
): ParsedPnpmPackages => {
|
|
75
|
+
const packages: PnpmLockPackages = new Map();
|
|
76
|
+
|
|
77
|
+
for (const resolution of Object.keys(lock.packages ?? {})) {
|
|
78
|
+
const { name, version } = parsePackageId(resolution);
|
|
79
|
+
packages.set(resolution, {
|
|
80
|
+
type: isNpmVersion(version) ? "npm" : "other",
|
|
81
|
+
name,
|
|
82
|
+
version,
|
|
83
|
+
resolution,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const installationsByResolution = new Map<string, string[]>();
|
|
88
|
+
for (const snapshotKey of Object.keys(lock.snapshots ?? {})) {
|
|
89
|
+
const baseId = stripPeerSuffix(snapshotKey);
|
|
90
|
+
const installations = installationsByResolution.get(baseId);
|
|
91
|
+
if (installations) {
|
|
92
|
+
installations.push(snapshotKey);
|
|
93
|
+
} else {
|
|
94
|
+
installationsByResolution.set(baseId, [snapshotKey]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { packages, installationsByResolution };
|
|
99
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import semver from "semver";
|
|
2
|
+
import { runPnpm } from "./runPnpm.ts";
|
|
3
|
+
import type { PnpmRunner } from "./runPnpm.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convergence overrides (`overrides: { "pkg@": "1.2.3" }`, empty range selector)
|
|
7
|
+
* landed in pnpm 11.13.0. They rewrite a dependency edge only when the declared
|
|
8
|
+
* range accepts the exact version, which is what lets a cluster converge without
|
|
9
|
+
* forcing the members a third-party range legitimately pins elsewhere.
|
|
10
|
+
*/
|
|
11
|
+
export const convergenceOverridesMinVersion = "11.13.0";
|
|
12
|
+
|
|
13
|
+
export const readPnpmVersion = (run: PnpmRunner = runPnpm): string | null => {
|
|
14
|
+
const result = run(["--version"], { stdio: "pipe" });
|
|
15
|
+
if (result.status !== 0) return null;
|
|
16
|
+
// pnpm prints its warnings (a `workspaces` field in package.json, an update
|
|
17
|
+
// notice) into the same captured output, so the version is the last line that
|
|
18
|
+
// is one, not the last line.
|
|
19
|
+
return (
|
|
20
|
+
result.output
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.findLast((line) => semver.valid(line) !== null) ?? null
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const supportsConvergenceOverrides = (version: string): boolean =>
|
|
28
|
+
semver.gte(version, convergenceOverridesMinVersion);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it } from "bun:test";
|
|
2
|
+
import { deepStrictEqual, strictEqual } from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
addOverrides,
|
|
5
|
+
overrideKey,
|
|
6
|
+
readConvergenceOverrides,
|
|
7
|
+
} from "./pnpmWorkspaceYaml.ts";
|
|
8
|
+
|
|
9
|
+
const overrides = (entries: [string, string][]) => new Map(entries);
|
|
10
|
+
|
|
11
|
+
describe("pnpmWorkspaceYaml", () => {
|
|
12
|
+
it("uses the empty range selector pnpm reads as a convergence override", () => {
|
|
13
|
+
strictEqual(overrideKey("metro-config", true), "metro-config@");
|
|
14
|
+
strictEqual(
|
|
15
|
+
overrideKey("@react-native/metro-config", true),
|
|
16
|
+
"@react-native/metro-config@",
|
|
17
|
+
);
|
|
18
|
+
strictEqual(overrideKey("metro-config", false), "metro-config");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("creates the file content when there is none", () => {
|
|
22
|
+
strictEqual(
|
|
23
|
+
addOverrides(undefined, overrides([["metro-config", "0.87.0"]])),
|
|
24
|
+
'overrides:\n "metro-config@": "0.87.0"\n',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("keeps comments, sibling keys and formatting", () => {
|
|
29
|
+
const content = [
|
|
30
|
+
"# why this repo pins its resolution mode",
|
|
31
|
+
"resolutionMode: time-based # inline",
|
|
32
|
+
"",
|
|
33
|
+
"packages:",
|
|
34
|
+
" - packages/*",
|
|
35
|
+
"",
|
|
36
|
+
"# trailing note",
|
|
37
|
+
"",
|
|
38
|
+
].join("\n");
|
|
39
|
+
|
|
40
|
+
strictEqual(
|
|
41
|
+
addOverrides(content, overrides([["metro", "0.84.5"]])),
|
|
42
|
+
[
|
|
43
|
+
"# why this repo pins its resolution mode",
|
|
44
|
+
"resolutionMode: time-based # inline",
|
|
45
|
+
"",
|
|
46
|
+
"packages:",
|
|
47
|
+
" - packages/*",
|
|
48
|
+
"overrides:",
|
|
49
|
+
' "metro@": "0.84.5"',
|
|
50
|
+
"",
|
|
51
|
+
"# trailing note",
|
|
52
|
+
"",
|
|
53
|
+
].join("\n"),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("adds to an existing overrides block without touching its entries", () => {
|
|
58
|
+
const content = [
|
|
59
|
+
"overrides:",
|
|
60
|
+
" # a decision taken elsewhere",
|
|
61
|
+
' "foo@": "1.0.0"',
|
|
62
|
+
"resolutionMode: time-based",
|
|
63
|
+
"",
|
|
64
|
+
].join("\n");
|
|
65
|
+
|
|
66
|
+
strictEqual(
|
|
67
|
+
addOverrides(content, overrides([["bar", "2.0.0"]])),
|
|
68
|
+
[
|
|
69
|
+
"overrides:",
|
|
70
|
+
" # a decision taken elsewhere",
|
|
71
|
+
' "foo@": "1.0.0"',
|
|
72
|
+
' "bar@": "2.0.0"',
|
|
73
|
+
"resolutionMode: time-based",
|
|
74
|
+
"",
|
|
75
|
+
].join("\n"),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("quotes the scoped keys and the versions yaml would read as numbers", () => {
|
|
80
|
+
strictEqual(
|
|
81
|
+
addOverrides(
|
|
82
|
+
undefined,
|
|
83
|
+
overrides([
|
|
84
|
+
["@react-native/metro-config", "0.87.0"],
|
|
85
|
+
["single-digit", "4"],
|
|
86
|
+
]),
|
|
87
|
+
),
|
|
88
|
+
[
|
|
89
|
+
"overrides:",
|
|
90
|
+
' "@react-native/metro-config@": "0.87.0"',
|
|
91
|
+
' "single-digit@": "4"',
|
|
92
|
+
"",
|
|
93
|
+
].join("\n"),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("puts the comment above the first entry it adds, not above the block", () => {
|
|
98
|
+
strictEqual(
|
|
99
|
+
addOverrides(
|
|
100
|
+
'overrides:\n # a decision taken elsewhere\n "foo@": "1.0.0"\n',
|
|
101
|
+
overrides([
|
|
102
|
+
["leaf", "2.0.0"],
|
|
103
|
+
["other", "1.0.0"],
|
|
104
|
+
]),
|
|
105
|
+
{ comment: "Added by pnpm-dedup.\nsee https://example.test/issues" },
|
|
106
|
+
),
|
|
107
|
+
[
|
|
108
|
+
"overrides:",
|
|
109
|
+
" # a decision taken elsewhere",
|
|
110
|
+
' "foo@": "1.0.0"',
|
|
111
|
+
" # Added by pnpm-dedup.",
|
|
112
|
+
" # see https://example.test/issues",
|
|
113
|
+
' "leaf@": "2.0.0"',
|
|
114
|
+
' "other@": "1.0.0"',
|
|
115
|
+
"",
|
|
116
|
+
].join("\n"),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("writes plain keys when convergence is disabled", () => {
|
|
121
|
+
strictEqual(
|
|
122
|
+
addOverrides(undefined, overrides([["metro", "0.84.5"]]), {
|
|
123
|
+
convergence: false,
|
|
124
|
+
}),
|
|
125
|
+
'overrides:\n "metro": "0.84.5"\n',
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("reads back only the convergence overrides", () => {
|
|
130
|
+
const content = addOverrides(
|
|
131
|
+
'overrides:\n "plain-override": "1.0.0"\n',
|
|
132
|
+
overrides([["metro", "0.84.5"]]),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
deepStrictEqual(
|
|
136
|
+
[...readConvergenceOverrides(content)],
|
|
137
|
+
[["metro", "0.84.5"]],
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Scalar, parseDocument } from "yaml";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A convergence override (pnpm >= 11.13.0) is an override key with an empty
|
|
5
|
+
* range selector. It repoints a dependency edge only when the edge's declared
|
|
6
|
+
* range accepts the exact version, so the members a third-party range
|
|
7
|
+
* legitimately pins elsewhere keep their own resolution instead of being forced.
|
|
8
|
+
* The plain key is the unconditional one: every requester gets the version.
|
|
9
|
+
*/
|
|
10
|
+
export const overrideKey = (
|
|
11
|
+
packageName: string,
|
|
12
|
+
convergence: boolean,
|
|
13
|
+
): string => (convergence ? `${packageName}@` : packageName);
|
|
14
|
+
|
|
15
|
+
// `yaml` writes `commentBefore` straight after the `#`, so the leading space
|
|
16
|
+
// that makes it read as prose has to be part of every line.
|
|
17
|
+
const spaced = (comment: string): string =>
|
|
18
|
+
comment
|
|
19
|
+
.split("\n")
|
|
20
|
+
.map((line) => (line.startsWith(" ") ? line : ` ${line}`))
|
|
21
|
+
.join("\n");
|
|
22
|
+
|
|
23
|
+
const quoted = (value: string): Scalar<string> => {
|
|
24
|
+
const scalar = new Scalar(value);
|
|
25
|
+
scalar.type = Scalar.QUOTE_DOUBLE;
|
|
26
|
+
return scalar;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface AddOverridesOptions {
|
|
30
|
+
// false writes plain keys, which pnpm applies to every requester
|
|
31
|
+
convergence?: boolean;
|
|
32
|
+
comment?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add overrides to a `pnpm-workspace.yaml`, editing the document rather than
|
|
37
|
+
* reserializing it: the user's comments, key order and formatting have to
|
|
38
|
+
* survive. Pass `undefined` for a file that does not exist yet.
|
|
39
|
+
*
|
|
40
|
+
* `comment` is attached above the first entry added, not above the `overrides`
|
|
41
|
+
* key, so an existing block and whatever the user wrote about it stay untouched.
|
|
42
|
+
*/
|
|
43
|
+
export const addOverrides = (
|
|
44
|
+
content: string | undefined,
|
|
45
|
+
overrides: Map<string, string>,
|
|
46
|
+
{ convergence = true, comment }: AddOverridesOptions = {},
|
|
47
|
+
): string => {
|
|
48
|
+
const doc = parseDocument(content ?? "");
|
|
49
|
+
let isFirst = true;
|
|
50
|
+
|
|
51
|
+
for (const [packageName, version] of overrides) {
|
|
52
|
+
const key = quoted(overrideKey(packageName, convergence));
|
|
53
|
+
if (isFirst && comment !== undefined) {
|
|
54
|
+
key.commentBefore = spaced(comment);
|
|
55
|
+
}
|
|
56
|
+
doc.setIn(["overrides", key], quoted(version));
|
|
57
|
+
isFirst = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return doc.toString();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const readConvergenceOverrides = (
|
|
64
|
+
content: string,
|
|
65
|
+
): Map<string, string> => {
|
|
66
|
+
const parsed = parseDocument(content).toJS() as {
|
|
67
|
+
overrides?: Record<string, unknown>;
|
|
68
|
+
} | null;
|
|
69
|
+
|
|
70
|
+
return new Map(
|
|
71
|
+
Object.entries(parsed?.overrides ?? {}).flatMap(([key, value]) =>
|
|
72
|
+
key.endsWith("@") && typeof value === "string"
|
|
73
|
+
? [[key.slice(0, -1), value] as [string, string]]
|
|
74
|
+
: [],
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { resolveProjectDir } from "pm-dedup-core";
|
|
3
|
+
|
|
4
|
+
const lockfileName = "pnpm-lock.yaml";
|
|
5
|
+
|
|
6
|
+
export const lockPathOf = (projectDir: string): string =>
|
|
7
|
+
join(projectDir, lockfileName);
|
|
8
|
+
|
|
9
|
+
// The project the run operates on, found by walking up from the working
|
|
10
|
+
// directory, so the bins work from any subdirectory of it. Null when no
|
|
11
|
+
// pnpm-lock.yaml was found: the reason is printed and the exit code set, as
|
|
12
|
+
// `parseBinArgs` does.
|
|
13
|
+
export const resolvePnpmProjectDir = (): string | null =>
|
|
14
|
+
resolveProjectDir({ lockfileName });
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it } from "bun:test";
|
|
2
|
+
import { deepStrictEqual, strictEqual } from "node:assert/strict";
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { createManifestReaderWithStats } from "./readInstalledManifest.ts";
|
|
8
|
+
|
|
9
|
+
const hoistedFixture = fileURLToPath(
|
|
10
|
+
new URL("../../test/fixtures/hoisted-node-linker", import.meta.url),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
describe("createManifestReaderWithStats", () => {
|
|
14
|
+
it("reads a hoisted layout, top-level and nested", () => {
|
|
15
|
+
const { readManifest, stats } =
|
|
16
|
+
createManifestReaderWithStats(hoistedFixture);
|
|
17
|
+
|
|
18
|
+
// hoisted at the top of node_modules
|
|
19
|
+
deepStrictEqual(readManifest("mini-metro", "0.84.5")?.dependencies, {
|
|
20
|
+
"mini-metro-config": "0.84.5",
|
|
21
|
+
});
|
|
22
|
+
// the version that lost the hoist, nested under its dependent
|
|
23
|
+
deepStrictEqual(readManifest("mini-metro", "0.87.0")?.dependencies, {
|
|
24
|
+
"mini-metro-config": "0.87.0",
|
|
25
|
+
});
|
|
26
|
+
// the open range this whole scenario turns on
|
|
27
|
+
deepStrictEqual(readManifest("mini-plugin", "1.0.0")?.dependencies, {
|
|
28
|
+
"mini-metro-config": "*",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
deepStrictEqual(stats(), { found: 3, missed: 0 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("resolves a location only pnpm's own index knows", () => {
|
|
35
|
+
const { readManifest } = createManifestReaderWithStats(hoistedFixture);
|
|
36
|
+
|
|
37
|
+
// three levels down: reachable from `.modules.yaml`'s hoistedLocations,
|
|
38
|
+
// not from scanning node_modules one level deep
|
|
39
|
+
deepStrictEqual(readManifest("mini-deep", "2.0.0")?.dependencies, {
|
|
40
|
+
"mini-metro-config": "*",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not return a manifest whose version differs", () => {
|
|
45
|
+
const { readManifest, stats } =
|
|
46
|
+
createManifestReaderWithStats(hoistedFixture);
|
|
47
|
+
|
|
48
|
+
strictEqual(readManifest("mini-metro", "9.9.9"), undefined);
|
|
49
|
+
deepStrictEqual(stats(), { found: 0, missed: 1 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reads pnpm's virtual store layout, peer suffix included", () => {
|
|
53
|
+
const dir = mkdtempSync(join(tmpdir(), "pnpm-dedup-virtual-store-"));
|
|
54
|
+
try {
|
|
55
|
+
const packageDir = join(
|
|
56
|
+
dir,
|
|
57
|
+
"node_modules",
|
|
58
|
+
".pnpm",
|
|
59
|
+
"@scope+pkg@1.2.3(react@19.0.0)",
|
|
60
|
+
"node_modules",
|
|
61
|
+
"@scope",
|
|
62
|
+
"pkg",
|
|
63
|
+
);
|
|
64
|
+
mkdirSync(packageDir, { recursive: true });
|
|
65
|
+
writeFileSync(
|
|
66
|
+
join(packageDir, "package.json"),
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
name: "@scope/pkg",
|
|
69
|
+
version: "1.2.3",
|
|
70
|
+
peerDependencies: { react: "*" },
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const { readManifest, stats } = createManifestReaderWithStats(dir);
|
|
75
|
+
deepStrictEqual(readManifest("@scope/pkg", "1.2.3")?.peerDependencies, {
|
|
76
|
+
react: "*",
|
|
77
|
+
});
|
|
78
|
+
deepStrictEqual(stats(), { found: 1, missed: 0 });
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("honors a relocated virtual store", () => {
|
|
85
|
+
const dir = mkdtempSync(join(tmpdir(), "pnpm-dedup-relocated-store-"));
|
|
86
|
+
try {
|
|
87
|
+
const packageDir = join(
|
|
88
|
+
dir,
|
|
89
|
+
"node_modules",
|
|
90
|
+
".pnpm-store",
|
|
91
|
+
"pkg@1.0.0",
|
|
92
|
+
"node_modules",
|
|
93
|
+
"pkg",
|
|
94
|
+
);
|
|
95
|
+
mkdirSync(packageDir, { recursive: true });
|
|
96
|
+
writeFileSync(
|
|
97
|
+
join(packageDir, "package.json"),
|
|
98
|
+
JSON.stringify({ name: "pkg", version: "1.0.0", dependencies: {} }),
|
|
99
|
+
);
|
|
100
|
+
writeFileSync(
|
|
101
|
+
join(dir, "node_modules", ".modules.yaml"),
|
|
102
|
+
JSON.stringify({
|
|
103
|
+
nodeLinker: "isolated",
|
|
104
|
+
virtualStoreDir: ".pnpm-store",
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const { readManifest } = createManifestReaderWithStats(dir);
|
|
109
|
+
strictEqual(readManifest("pkg", "1.0.0")?.version, "1.0.0");
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(dir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("reports every read as missed when nothing is installed", () => {
|
|
116
|
+
const { readManifest, stats } = createManifestReaderWithStats(
|
|
117
|
+
join(tmpdir(), "pnpm-dedup-not-installed"),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
strictEqual(readManifest("mini-metro", "0.84.5"), undefined);
|
|
121
|
+
strictEqual(readManifest("mini-plugin", "1.0.0"), undefined);
|
|
122
|
+
deepStrictEqual(stats(), { found: 0, missed: 2 });
|
|
123
|
+
});
|
|
124
|
+
});
|