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,202 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { stripPeerSuffix } from "./parsePnpmLockPackages.ts";
|
|
5
|
+
|
|
6
|
+
export interface InstalledManifest {
|
|
7
|
+
version?: string;
|
|
8
|
+
dependencies?: Record<string, string>;
|
|
9
|
+
optionalDependencies?: Record<string, string>;
|
|
10
|
+
peerDependencies?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ManifestReader = (
|
|
14
|
+
name: string,
|
|
15
|
+
version: string,
|
|
16
|
+
) => InstalledManifest | undefined;
|
|
17
|
+
|
|
18
|
+
export interface ManifestReaderStats {
|
|
19
|
+
found: number;
|
|
20
|
+
missed: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const readManifestFile = (path: string): InstalledManifest | undefined => {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(path, "utf8")) as InstalledManifest;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const readdirOrEmpty = (path: string): string[] => {
|
|
32
|
+
try {
|
|
33
|
+
return readdirSync(path);
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// pnpm records where it put every package in `node_modules/.modules.yaml`
|
|
40
|
+
// (`hoistedLocations`), keyed by dep path — `metro@0.87.0(supports-color@8.1.1)`
|
|
41
|
+
// -> `["node_modules/@tamagui/metro-plugin/node_modules/metro"]`. It is written
|
|
42
|
+
// as JSON by current pnpm and as YAML by older ones.
|
|
43
|
+
interface ModulesState {
|
|
44
|
+
virtualStoreDir: string;
|
|
45
|
+
// dep path (peer suffix stripped) -> paths, relative to the project dir
|
|
46
|
+
hoistedLocations: Map<string, string[]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const readModulesState = (nodeModulesDir: string): ModulesState => {
|
|
50
|
+
const hoistedLocations = new Map<string, string[]>();
|
|
51
|
+
|
|
52
|
+
const parsed = ((): Record<string, unknown> | undefined => {
|
|
53
|
+
try {
|
|
54
|
+
const content = parseYaml(
|
|
55
|
+
readFileSync(join(nodeModulesDir, ".modules.yaml"), "utf8"),
|
|
56
|
+
) as unknown;
|
|
57
|
+
return typeof content === "object" && content !== null
|
|
58
|
+
? (content as Record<string, unknown>)
|
|
59
|
+
: undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
|
|
65
|
+
const hoisted = parsed?.hoistedLocations;
|
|
66
|
+
if (typeof hoisted === "object" && hoisted !== null) {
|
|
67
|
+
for (const [depPath, paths] of Object.entries(
|
|
68
|
+
hoisted as Record<string, unknown>,
|
|
69
|
+
)) {
|
|
70
|
+
if (!Array.isArray(paths)) continue;
|
|
71
|
+
const key = stripPeerSuffix(depPath);
|
|
72
|
+
hoistedLocations.set(key, [
|
|
73
|
+
...(hoistedLocations.get(key) ?? []),
|
|
74
|
+
...paths.filter((path): path is string => typeof path === "string"),
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const virtualStoreDir = parsed?.virtualStoreDir;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
virtualStoreDir:
|
|
83
|
+
typeof virtualStoreDir === "string" ? virtualStoreDir : ".pnpm",
|
|
84
|
+
hoistedLocations,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Build a reader that locates an installed package's manifest, whichever
|
|
89
|
+
* node-linker the project uses:
|
|
90
|
+
*
|
|
91
|
+
* - virtual store (pnpm's default): the dep path gives the directory,
|
|
92
|
+
* `node_modules/.pnpm/<name with / replaced by +>@<version>[(peers)|_hash]/node_modules/<name>`;
|
|
93
|
+
* - `nodeLinker: hoisted`: the layout is npm-shaped and placement is a hoisting
|
|
94
|
+
* decision, so `node_modules/.modules.yaml` is consulted for the exact paths;
|
|
95
|
+
* a flat `node_modules/<name>` and one level of nesting are tried as a
|
|
96
|
+
* fallback when that file is missing.
|
|
97
|
+
*
|
|
98
|
+
* `stats` tells callers whether anything was readable at all: every declared
|
|
99
|
+
* range comes from these manifests, so a reader that finds nothing silently
|
|
100
|
+
* turns every dependent's range into an exact pin and hides every fix.
|
|
101
|
+
*/
|
|
102
|
+
export const createManifestReaderWithStats = (
|
|
103
|
+
projectDir: string,
|
|
104
|
+
): { readManifest: ManifestReader; stats: () => ManifestReaderStats } => {
|
|
105
|
+
const nodeModulesDir = join(projectDir, "node_modules");
|
|
106
|
+
const modules = readModulesState(nodeModulesDir);
|
|
107
|
+
// `virtualStoreDir` is configurable and recorded in `.modules.yaml`, relative
|
|
108
|
+
// to node_modules unless absolute
|
|
109
|
+
const pnpmDir = isAbsolute(modules.virtualStoreDir)
|
|
110
|
+
? modules.virtualStoreDir
|
|
111
|
+
: join(nodeModulesDir, modules.virtualStoreDir);
|
|
112
|
+
const cache = new Map<string, InstalledManifest | undefined>();
|
|
113
|
+
const stats: ManifestReaderStats = { found: 0, missed: 0 };
|
|
114
|
+
|
|
115
|
+
const virtualStoreEntries = readdirOrEmpty(pnpmDir);
|
|
116
|
+
const hoistedLocations = modules.hoistedLocations;
|
|
117
|
+
// top-level package dirs, for the nested lookup of the hoisted layout
|
|
118
|
+
const hoistedDependents = (() => {
|
|
119
|
+
let dirs: string[] | undefined;
|
|
120
|
+
return (): string[] => {
|
|
121
|
+
dirs ??= readdirOrEmpty(nodeModulesDir).flatMap((entry) =>
|
|
122
|
+
entry.startsWith("@")
|
|
123
|
+
? readdirOrEmpty(join(nodeModulesDir, entry)).map((scoped) =>
|
|
124
|
+
join(entry, scoped),
|
|
125
|
+
)
|
|
126
|
+
: [entry],
|
|
127
|
+
);
|
|
128
|
+
return dirs;
|
|
129
|
+
};
|
|
130
|
+
})();
|
|
131
|
+
|
|
132
|
+
const fromVirtualStore = (
|
|
133
|
+
name: string,
|
|
134
|
+
version: string,
|
|
135
|
+
): InstalledManifest | undefined => {
|
|
136
|
+
const target = `${name.replaceAll("/", "+")}@${version}`;
|
|
137
|
+
const dir = virtualStoreEntries.find(
|
|
138
|
+
(entry) =>
|
|
139
|
+
entry === target ||
|
|
140
|
+
entry.startsWith(`${target}(`) ||
|
|
141
|
+
entry.startsWith(`${target}_`),
|
|
142
|
+
);
|
|
143
|
+
return dir
|
|
144
|
+
? readManifestFile(
|
|
145
|
+
join(pnpmDir, dir, "node_modules", name, "package.json"),
|
|
146
|
+
)
|
|
147
|
+
: undefined;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const fromHoisted = (
|
|
151
|
+
name: string,
|
|
152
|
+
version: string,
|
|
153
|
+
): InstalledManifest | undefined => {
|
|
154
|
+
const atVersion = (path: string): InstalledManifest | undefined => {
|
|
155
|
+
const manifest = readManifestFile(path);
|
|
156
|
+
return manifest?.version === version ? manifest : undefined;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// the recorded locations are relative to the project directory
|
|
160
|
+
for (const location of hoistedLocations.get(`${name}@${version}`) ?? []) {
|
|
161
|
+
const recorded = atVersion(join(projectDir, location, "package.json"));
|
|
162
|
+
if (recorded) return recorded;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const hoisted = atVersion(join(nodeModulesDir, name, "package.json"));
|
|
166
|
+
if (hoisted) return hoisted;
|
|
167
|
+
|
|
168
|
+
// no `.modules.yaml`: a version that could not be hoisted lives under
|
|
169
|
+
// whichever dependent needed it, so try one level of nesting
|
|
170
|
+
for (const dependent of hoistedDependents()) {
|
|
171
|
+
const nested = atVersion(
|
|
172
|
+
join(nodeModulesDir, dependent, "node_modules", name, "package.json"),
|
|
173
|
+
);
|
|
174
|
+
if (nested) return nested;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return undefined;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const readManifest: ManifestReader = (name, version) => {
|
|
181
|
+
const cacheKey = `${name}@${version}`;
|
|
182
|
+
if (cache.has(cacheKey)) {
|
|
183
|
+
return cache.get(cacheKey);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const manifest =
|
|
187
|
+
fromVirtualStore(name, version) ?? fromHoisted(name, version);
|
|
188
|
+
if (manifest) {
|
|
189
|
+
stats.found += 1;
|
|
190
|
+
} else {
|
|
191
|
+
stats.missed += 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
cache.set(cacheKey, manifest);
|
|
195
|
+
return manifest;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return { readManifest, stats: () => ({ ...stats }) };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const createManifestReader = (projectDir: string): ManifestReader =>
|
|
202
|
+
createManifestReaderWithStats(projectDir).readManifest;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface PnpmRunResult {
|
|
4
|
+
status: number | null;
|
|
5
|
+
output: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RunPnpmOptions {
|
|
9
|
+
cwd?: string;
|
|
10
|
+
// inherit to stream pnpm's own reporter, pipe to capture it
|
|
11
|
+
stdio?: "inherit" | "pipe";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PnpmRunner = (
|
|
15
|
+
args: string[],
|
|
16
|
+
options?: RunPnpmOptions,
|
|
17
|
+
) => PnpmRunResult;
|
|
18
|
+
|
|
19
|
+
export const runPnpm: PnpmRunner = (args, { cwd, stdio = "inherit" } = {}) => {
|
|
20
|
+
const result = spawnSync("pnpm", args, {
|
|
21
|
+
cwd,
|
|
22
|
+
stdio,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (result.error) {
|
|
27
|
+
throw result.error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
status: result.status,
|
|
32
|
+
output: stdio === "pipe" ? `${result.stdout}\n${result.stderr}` : "",
|
|
33
|
+
};
|
|
34
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Scratch projects for the tests that need a real directory on disk. The
|
|
6
|
+
// end-to-end ones run an actual install in theirs, so a leaked directory is
|
|
7
|
+
// tens of megabytes.
|
|
8
|
+
//
|
|
9
|
+
// `afterEach` covers the normal path — a passing, failing or timed-out test all
|
|
10
|
+
// reach it — but not a signal: on Ctrl-C the test runner exits without running
|
|
11
|
+
// the remaining hooks, and on SIGKILL nothing runs at all. So cleanup does not
|
|
12
|
+
// rest on it alone:
|
|
13
|
+
// - the directory name carries the pid that owns it, and every run first
|
|
14
|
+
// reaps the leftovers of pids that are gone,
|
|
15
|
+
// - SIGINT/SIGTERM clean up before the signal is allowed through.
|
|
16
|
+
|
|
17
|
+
const isRunning = (pid: number): boolean => {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
// EPERM: alive, but owned by another user — not ours to reap either way
|
|
23
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const remove = (dir: string): void => {
|
|
28
|
+
try {
|
|
29
|
+
rmSync(dir, { recursive: true, force: true });
|
|
30
|
+
} catch {
|
|
31
|
+
// a directory a still-running install is writing into can fail to unlink;
|
|
32
|
+
// the next run reaps it once that pid is gone
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// the `${pid}-${mkdtemp suffix}` a `create` call leaves after the prefix. Names
|
|
37
|
+
// that do not match are left alone: a random suffix read as a pid would reap
|
|
38
|
+
// directories this helper never made.
|
|
39
|
+
const ownerPidPattern = /^(?<pid>[1-9]\d*)-/u;
|
|
40
|
+
|
|
41
|
+
const reapAbandoned = (prefix: string): void => {
|
|
42
|
+
const root = tmpdir();
|
|
43
|
+
for (const name of readdirSync(root)) {
|
|
44
|
+
if (!name.startsWith(prefix)) continue;
|
|
45
|
+
const owner = ownerPidPattern.exec(name.slice(prefix.length))?.groups?.pid;
|
|
46
|
+
if (!owner || isRunning(Number(owner))) continue;
|
|
47
|
+
remove(join(root, name));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const pendingCleanups = new Set<() => void>();
|
|
52
|
+
let signalsHooked = false;
|
|
53
|
+
|
|
54
|
+
const hookSignals = (): void => {
|
|
55
|
+
if (signalsHooked) return;
|
|
56
|
+
signalsHooked = true;
|
|
57
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
58
|
+
const handler = (): void => {
|
|
59
|
+
for (const cleanup of pendingCleanups) cleanup();
|
|
60
|
+
// a listener suppresses the default termination: drop it and re-raise so
|
|
61
|
+
// the process still dies of the signal it was sent
|
|
62
|
+
process.removeListener(signal, handler);
|
|
63
|
+
process.kill(process.pid, signal);
|
|
64
|
+
};
|
|
65
|
+
process.on(signal, handler);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface TempProjects {
|
|
70
|
+
/** A new empty directory, removed by `cleanup`. */
|
|
71
|
+
create: () => string;
|
|
72
|
+
/** Removes every directory created since the last call. */
|
|
73
|
+
cleanup: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const createTempProjects = (prefix: string): TempProjects => {
|
|
77
|
+
reapAbandoned(prefix);
|
|
78
|
+
hookSignals();
|
|
79
|
+
|
|
80
|
+
const dirs: string[] = [];
|
|
81
|
+
const cleanup = (): void => {
|
|
82
|
+
for (const dir of dirs.splice(0)) remove(dir);
|
|
83
|
+
};
|
|
84
|
+
pendingCleanups.add(cleanup);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
create: (): string => {
|
|
88
|
+
const dir = mkdtempSync(join(tmpdir(), `${prefix}${process.pid}-`));
|
|
89
|
+
dirs.push(dir);
|
|
90
|
+
return dir;
|
|
91
|
+
},
|
|
92
|
+
cleanup,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it } from "bun:test";
|
|
2
|
+
import { deepStrictEqual, ok, strictEqual } from "node:assert/strict";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { PnpmLockFile } from "../pnpmLockTypes.ts";
|
|
5
|
+
import { readPnpmLock } from "../readPnpmLock.ts";
|
|
6
|
+
import { buildPnpmPackagesMap } from "./buildPnpmPackagesMap.ts";
|
|
7
|
+
import { parsePnpmLockPackages } from "./parsePnpmLockPackages.ts";
|
|
8
|
+
import { toLockstepGraph } from "./toLockstepGraph.ts";
|
|
9
|
+
|
|
10
|
+
const fixturesBase = (rel: string) =>
|
|
11
|
+
fileURLToPath(new URL(rel, import.meta.url));
|
|
12
|
+
|
|
13
|
+
const graphOf = (lock: PnpmLockFile) =>
|
|
14
|
+
toLockstepGraph(lock, buildPnpmPackagesMap(parsePnpmLockPackages(lock)));
|
|
15
|
+
|
|
16
|
+
const graphFor = (scenario: string) =>
|
|
17
|
+
graphOf(
|
|
18
|
+
readPnpmLock(
|
|
19
|
+
fixturesBase(`../../test/fixtures/${scenario}/pnpm-lock.yaml`),
|
|
20
|
+
),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
describe("toLockstepGraph", () => {
|
|
24
|
+
it("strips peer suffixes, resolves aliases, and keeps every installation", () => {
|
|
25
|
+
const graph = graphOf({
|
|
26
|
+
lockfileVersion: "9.0",
|
|
27
|
+
packages: {
|
|
28
|
+
"a@1.0.0": {},
|
|
29
|
+
"b@1.0.0": {},
|
|
30
|
+
"alias-target@2.0.0": {},
|
|
31
|
+
},
|
|
32
|
+
snapshots: {
|
|
33
|
+
"a@1.0.0(peer@1.0.0)": {
|
|
34
|
+
dependencies: {
|
|
35
|
+
b: "1.0.0(peer@1.0.0)",
|
|
36
|
+
aliased: "alias-target@2.0.0",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"a@1.0.0(peer@2.0.0)": {
|
|
40
|
+
dependencies: { b: "1.0.0" },
|
|
41
|
+
},
|
|
42
|
+
"b@1.0.0": {},
|
|
43
|
+
"alias-target@2.0.0": {},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// one entry per peer context, both carrying the same resolved version
|
|
48
|
+
deepStrictEqual(graph.a, [
|
|
49
|
+
{
|
|
50
|
+
version: "1.0.0",
|
|
51
|
+
isNpm: true,
|
|
52
|
+
dependencies: { b: "1.0.0", "alias-target": "2.0.0" },
|
|
53
|
+
},
|
|
54
|
+
{ version: "1.0.0", isNpm: true, dependencies: { b: "1.0.0" } },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("maps metro's exact self-pins to co-version edges", () => {
|
|
59
|
+
const metro = graphFor("wildcard-not-reused").metro;
|
|
60
|
+
ok(metro);
|
|
61
|
+
deepStrictEqual(metro.map((resolution) => resolution.version).toSorted(), [
|
|
62
|
+
"0.84.5",
|
|
63
|
+
"0.87.0",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
for (const resolution of metro) {
|
|
67
|
+
strictEqual(resolution.isNpm, true);
|
|
68
|
+
strictEqual(resolution.dependencies["metro-config"], resolution.version);
|
|
69
|
+
// an unrelated dependency keeps its own version line
|
|
70
|
+
ok(resolution.dependencies.debug?.startsWith("4."));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// `aliased-swapped-names`: the keys the importer uses name each other's
|
|
75
|
+
// package. The graph is keyed by npm name, so `@typescript/typescript6` has to
|
|
76
|
+
// stay its own name and `tool`'s edge has to land on the real `typescript`.
|
|
77
|
+
it("keeps swapped alias keys apart", () => {
|
|
78
|
+
const graph = graphFor("aliased-swapped-names");
|
|
79
|
+
|
|
80
|
+
deepStrictEqual(Object.keys(graph).toSorted(), [
|
|
81
|
+
"@typescript/typescript6",
|
|
82
|
+
"tool",
|
|
83
|
+
"typescript",
|
|
84
|
+
]);
|
|
85
|
+
deepStrictEqual(graph.tool?.[0]?.dependencies, { typescript: "5.9.3" });
|
|
86
|
+
strictEqual(graph.typescript?.length, 2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("marks non-npm resolutions so cluster detection skips them", () => {
|
|
90
|
+
const nonNpm = Object.values(graphFor("non-npm"))
|
|
91
|
+
.flat()
|
|
92
|
+
.filter((resolution) => !resolution.isNpm);
|
|
93
|
+
ok(nonNpm.length > 0);
|
|
94
|
+
for (const resolution of nonNpm) {
|
|
95
|
+
deepStrictEqual(resolution.dependencies, {});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { LockstepGraph, LockstepResolution } from "pm-dedup-core";
|
|
2
|
+
import type { PnpmLockFile } from "../pnpmLockTypes.ts";
|
|
3
|
+
import type { PackagesMap } from "./buildPnpmPackagesMap.ts";
|
|
4
|
+
import { resolveSnapshotDependency } from "./parsePnpmLockPackages.ts";
|
|
5
|
+
|
|
6
|
+
const snapshotDepTypes = ["dependencies", "optionalDependencies"] as const;
|
|
7
|
+
|
|
8
|
+
const snapshotDependencies = (
|
|
9
|
+
lock: PnpmLockFile,
|
|
10
|
+
snapshotKey: string,
|
|
11
|
+
): Record<string, string> => {
|
|
12
|
+
const snapshot = lock.snapshots?.[snapshotKey];
|
|
13
|
+
const dependencies: Record<string, string> = {};
|
|
14
|
+
|
|
15
|
+
snapshotDepTypes.forEach((depType) => {
|
|
16
|
+
for (const [depName, depValue] of Object.entries(
|
|
17
|
+
snapshot?.[depType] ?? {},
|
|
18
|
+
)) {
|
|
19
|
+
const resolved = resolveSnapshotDependency(depName, depValue);
|
|
20
|
+
dependencies[resolved.name] = resolved.version;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return dependencies;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Adapt the pnpm lockfile model into the package-manager-neutral graph consumed
|
|
29
|
+
* by `buildLockstepClusters`. pnpm snapshots store the *resolved* version of
|
|
30
|
+
* each dependency instead of the requested range, which co-version detection
|
|
31
|
+
* handles; one entry is emitted per installation, so a resolution installed in
|
|
32
|
+
* several peer contexts contributes every context it appears in.
|
|
33
|
+
*/
|
|
34
|
+
export const toLockstepGraph = (
|
|
35
|
+
lock: PnpmLockFile,
|
|
36
|
+
packagesMap: PackagesMap,
|
|
37
|
+
): LockstepGraph =>
|
|
38
|
+
Object.fromEntries(
|
|
39
|
+
Object.entries(packagesMap).map(([name, resolutions]) => [
|
|
40
|
+
name,
|
|
41
|
+
resolutions.flatMap((resolution): LockstepResolution[] => {
|
|
42
|
+
if (resolution.package.type !== "npm") {
|
|
43
|
+
return [{ version: "", isNpm: false, dependencies: {} }];
|
|
44
|
+
}
|
|
45
|
+
return resolution.installations.map((snapshotKey) => ({
|
|
46
|
+
version: resolution.package.version,
|
|
47
|
+
isNpm: true,
|
|
48
|
+
dependencies: snapshotDependencies(lock, snapshotKey),
|
|
49
|
+
}));
|
|
50
|
+
}),
|
|
51
|
+
]),
|
|
52
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { buildVersionsSnapshot } from "pm-dedup-core";
|
|
2
|
+
import type { VersionsSnapshot } from "pm-dedup-core";
|
|
3
|
+
import { readPnpmLock } from "../readPnpmLock.ts";
|
|
4
|
+
import { parsePnpmLockPackages } from "./parsePnpmLockPackages.ts";
|
|
5
|
+
|
|
6
|
+
export const readVersionsSnapshot = (lockPath: string): VersionsSnapshot =>
|
|
7
|
+
buildVersionsSnapshot(
|
|
8
|
+
parsePnpmLockPackages(readPnpmLock(lockPath)).packages.values(),
|
|
9
|
+
);
|