@pnpm/lockfile.filtering 1100.1.14 → 1100.2.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @pnpm/filter-lockfile
2
2
 
3
+ ## 1100.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies:
8
+ - @pnpm/config.package-is-installable@1100.1.1
9
+ - @pnpm/deps.path@1100.0.13
10
+ - @pnpm/lockfile.types@1100.0.18
11
+ - @pnpm/lockfile.utils@1100.1.7
12
+ - @pnpm/lockfile.walker@1100.0.18
13
+ - @pnpm/types@1101.8.0
14
+
15
+ ## 1100.2.0
16
+
17
+ ### Minor Changes
18
+
19
+ - Fixed an installed optional dependency being left without one of its own required dependencies. When a package reached through `optionalDependencies` is installable on the current system but one of its regular `dependencies` is not, a lockfile-based install skipped that dependency and installed the parent anyway, so importing the parent failed with `MODULE_NOT_FOUND`. The dependency is now installed, and an install-check warning reports the incompatibility. A dependency is still only skipped when every path to it is optional, or when the package that pulls it in was itself skipped [#13286](https://github.com/pnpm/pnpm/issues/13286).
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies:
24
+ - @pnpm/config.package-is-installable@1100.1.0
25
+ - @pnpm/deps.path@1100.0.12
26
+ - @pnpm/lockfile.types@1100.0.17
27
+ - @pnpm/lockfile.utils@1100.1.6
28
+ - @pnpm/lockfile.walker@1100.0.17
29
+ - @pnpm/types@1101.7.0
30
+
3
31
  ## 1100.1.14
4
32
 
5
33
  ### Patch Changes
@@ -1,8 +1,19 @@
1
1
  import type { LockfileObject } from '@pnpm/lockfile.types';
2
- import type { DependenciesField, ProjectId, SupportedArchitectures } from '@pnpm/types';
2
+ import type { DependenciesField, DepPath, ProjectId, SupportedArchitectures } from '@pnpm/types';
3
3
  export interface FilterLockfileResult {
4
4
  lockfile: LockfileObject;
5
5
  selectedImporterIds: ProjectId[];
6
+ /**
7
+ * Dep paths reached by a non-optional edge from an importer or from a
8
+ * package that is itself part of the install. Their installability is
9
+ * evaluated as non-optional — an incompatible one fails the install under
10
+ * `engineStrict` instead of being skipped — even when the lockfile marks
11
+ * the snapshot `optional: true` because the subtree happens to hang off an
12
+ * optional dependency. Downstream consumers must classify by this set
13
+ * rather than by `pkgSnapshot.optional` so every stage of a headless
14
+ * install agrees with the resolver, which classifies by edge too.
15
+ */
16
+ requiredDepPaths: Set<DepPath>;
6
17
  }
7
18
  export declare function filterLockfileByEngine(lockfile: LockfileObject, opts: FilterLockfileOptions): FilterLockfileResult;
8
19
  export interface FilterLockfileOptions {
@@ -1,4 +1,4 @@
1
- import { packageIsInstallable } from '@pnpm/config.package-is-installable';
1
+ import { checkPackageInstallability, packageIsInstallable, } from '@pnpm/config.package-is-installable';
2
2
  import { WANTED_LOCKFILE } from '@pnpm/constants';
3
3
  import * as dp from '@pnpm/deps.path';
4
4
  import { LockfileMissingDependencyError } from '@pnpm/error';
@@ -13,13 +13,13 @@ export function filterLockfileByEngine(lockfile, opts) {
13
13
  }
14
14
  export function filterLockfileByImportersAndEngine(lockfile, importerIds, opts) {
15
15
  const importerIdSet = new Set(importerIds);
16
- const directDepPaths = toImporterDepPaths(lockfile, importerIds, {
16
+ const directDepEdges = toImporterDepPaths(lockfile, importerIds, {
17
17
  include: opts.include,
18
18
  importerIdSet,
19
19
  skipRuntimes: opts.skipRuntimes,
20
20
  });
21
- const packages = lockfile.packages != null
22
- ? pickPkgsWithAllDeps(lockfile, directDepPaths, importerIdSet, {
21
+ const { packages, requiredDepPaths } = lockfile.packages != null
22
+ ? pickPkgsWithAllDeps(lockfile, directDepEdges, importerIdSet, {
23
23
  currentEngine: opts.currentEngine,
24
24
  engineStrict: opts.engineStrict,
25
25
  failOnMissingDependencies: opts.failOnMissingDependencies,
@@ -30,7 +30,7 @@ export function filterLockfileByImportersAndEngine(lockfile, importerIds, opts)
30
30
  skipRuntimes: opts.skipRuntimes,
31
31
  supportedArchitectures: opts.supportedArchitectures,
32
32
  })
33
- : {};
33
+ : { packages: {}, requiredDepPaths: new Set() };
34
34
  const importers = mapValues((importer) => {
35
35
  const newImporter = filterImporter(importer, opts.include, { skipRuntimes: opts.skipRuntimes });
36
36
  if (newImporter.optionalDependencies != null) {
@@ -48,18 +48,133 @@ export function filterLockfileByImportersAndEngine(lockfile, importerIds, opts)
48
48
  packages,
49
49
  },
50
50
  selectedImporterIds: Array.from(importerIdSet),
51
+ requiredDepPaths,
51
52
  };
52
53
  }
53
- function pickPkgsWithAllDeps(lockfile, depPaths, importerIdSet, opts) {
54
- const pickedPackages = {};
55
- pkgAllDeps({ lockfile, pickedPackages, importerIdSet }, depPaths, true, opts);
56
- return pickedPackages;
54
+ function pickPkgsWithAllDeps(lockfile, depEdges, importerIdSet, opts) {
55
+ const ctx = {
56
+ lockfile,
57
+ pickedPackages: {},
58
+ importerIdSet,
59
+ installed: new Set(),
60
+ requiredDepPaths: new Set(),
61
+ evaluated: [],
62
+ edgesByDepPath: new Map(),
63
+ };
64
+ classifyDeps(ctx, depEdges, opts);
65
+ reportInstallability(ctx, opts);
66
+ pickSkippedDeps(ctx, depEdges, opts);
67
+ return { packages: ctx.pickedPackages, requiredDepPaths: ctx.requiredDepPaths };
57
68
  }
58
- function pkgAllDeps(ctx, depPaths, parentIsInstallable, opts) {
59
- for (const depPath of depPaths) {
60
- if (ctx.pickedPackages[depPath])
69
+ /**
70
+ * Work out which packages the install actually reaches, and over which kind
71
+ * of edge.
72
+ *
73
+ * An edge is only expanded once its source is known to be part of the
74
+ * install, so the optionality of the edge — not the `optional` flag the
75
+ * resolver propagated onto the snapshot — decides whether an incompatible
76
+ * package is skipped: only a package whose every inbound edge is optional
77
+ * stays out of the install.
78
+ *
79
+ * Traversal is breadth-first over edges rather than depth-first over
80
+ * packages so the verdict does not depend on which edge happens to reach a
81
+ * package first: a package left out on an optional edge is reconsidered when
82
+ * a non-optional one arrives later. Nothing is reported from here — a
83
+ * package can be visited once per inbound edge, and its installability must
84
+ * be reported exactly once, which [reportInstallability] does afterwards.
85
+ */
86
+ function classifyDeps(ctx, depEdges, opts) {
87
+ const incompatible = new Map();
88
+ // Walked with a cursor rather than `shift()`, which is O(n) per dequeue on
89
+ // a JS array and would make the walk quadratic on a large lockfile.
90
+ const queue = [...depEdges];
91
+ for (let next = 0; next < queue.length; next++) {
92
+ const { depPath, optional } = queue[next];
93
+ if (!optional)
94
+ ctx.requiredDepPaths.add(depPath);
95
+ if (ctx.installed.has(depPath))
61
96
  continue;
62
97
  const pkgSnapshot = ctx.lockfile.packages[depPath];
98
+ // Missing entries are reported by the closure pass, which reaches every
99
+ // dep path this one does.
100
+ if (!pkgSnapshot)
101
+ continue;
102
+ if (!incompatible.has(depPath)) {
103
+ ctx.evaluated.push(depPath);
104
+ // TODO: depPath is not the package ID. Should be fixed
105
+ incompatible.set(depPath, !opts.includeIncompatiblePackages && checkPackageInstallability(pkgSnapshot.id ?? depPath, toInstallabilityManifest(depPath, pkgSnapshot), {
106
+ nodeVersion: opts.currentEngine.nodeVersion,
107
+ optional: true,
108
+ supportedArchitectures: opts.supportedArchitectures,
109
+ }) != null);
110
+ }
111
+ if (optional && incompatible.get(depPath))
112
+ continue;
113
+ ctx.installed.add(depPath);
114
+ ctx.pickedPackages[depPath] = pkgSnapshot;
115
+ const edges = nextDepEdges(ctx, pkgSnapshot, opts);
116
+ ctx.edgesByDepPath.set(depPath, edges);
117
+ // Appended one by one: `push(...edges)` passes each edge as its own
118
+ // argument and overflows the engine's argument limit on a wide enough
119
+ // dependency list.
120
+ for (const edge of edges) {
121
+ queue.push(edge);
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Report each reached package once, against the edge kind it was classified
127
+ * by. This is where an incompatible package that a non-optional edge reaches
128
+ * fails the install under `engineStrict`, and where a skipped optional gets
129
+ * its `skippedOptionalDependencyLogger` entry.
130
+ */
131
+ function reportInstallability(ctx, opts) {
132
+ for (const depPath of ctx.evaluated) {
133
+ const pkgSnapshot = ctx.lockfile.packages[depPath];
134
+ const installable = opts.includeIncompatiblePackages ||
135
+ packageIsInstallable(pkgSnapshot.id ?? depPath, toInstallabilityManifest(depPath, pkgSnapshot), {
136
+ // A subtree hanging off an `optionalDependencies` entry stays
137
+ // best-effort: the dependency is installed so its dependent is not
138
+ // left broken, but an incompatibility inside it does not fail the
139
+ // install. Only a package no optional path reaches is fatal here.
140
+ engineStrict: opts.engineStrict && pkgSnapshot.optional !== true,
141
+ lockfileDir: opts.lockfileDir,
142
+ nodeVersion: opts.currentEngine.nodeVersion,
143
+ optional: !ctx.installed.has(depPath) || !ctx.requiredDepPaths.has(depPath),
144
+ supportedArchitectures: opts.supportedArchitectures,
145
+ }) !== false;
146
+ if (installable) {
147
+ opts.skipped.delete(depPath);
148
+ }
149
+ else {
150
+ opts.skipped.add(depPath);
151
+ }
152
+ }
153
+ }
154
+ function toInstallabilityManifest(depPath, pkgSnapshot) {
155
+ return {
156
+ ...nameVerFromPkgSnapshot(depPath, pkgSnapshot),
157
+ cpu: pkgSnapshot.cpu,
158
+ engines: pkgSnapshot.engines,
159
+ os: pkgSnapshot.os,
160
+ libc: pkgSnapshot.libc,
161
+ };
162
+ }
163
+ /**
164
+ * Extend the picked set over the packages that are reachable but not part of
165
+ * the install — an incompatible optional and everything below it. They stay
166
+ * in the filtered lockfile and are recorded as skipped, so a later install on
167
+ * a host that does support them can pick them up without a re-resolution.
168
+ */
169
+ function pickSkippedDeps(ctx, depEdges, opts) {
170
+ const queue = depEdges.map(({ depPath }) => depPath);
171
+ const visited = new Set();
172
+ for (let next = 0; next < queue.length; next++) {
173
+ const depPath = queue[next];
174
+ if (visited.has(depPath))
175
+ continue;
176
+ visited.add(depPath);
177
+ const pkgSnapshot = ctx.lockfile.packages[depPath];
63
178
  if (!pkgSnapshot && !depPath.startsWith('link:')) {
64
179
  if (opts.failOnMissingDependencies) {
65
180
  throw new LockfileMissingDependencyError(depPath);
@@ -67,87 +182,73 @@ function pkgAllDeps(ctx, depPaths, parentIsInstallable, opts) {
67
182
  lockfileLogger.debug(`No entry for "${depPath}" in ${WANTED_LOCKFILE}`);
68
183
  continue;
69
184
  }
70
- let installable;
71
- if (!parentIsInstallable) {
72
- installable = false;
185
+ if (!ctx.installed.has(depPath)) {
73
186
  if (!ctx.pickedPackages[depPath] && pkgSnapshot.optional === true) {
74
187
  opts.skipped.add(depPath);
75
188
  }
189
+ ctx.pickedPackages[depPath] = pkgSnapshot;
76
190
  }
77
- else {
78
- const pkg = {
79
- ...nameVerFromPkgSnapshot(depPath, pkgSnapshot),
80
- cpu: pkgSnapshot.cpu,
81
- engines: pkgSnapshot.engines,
82
- os: pkgSnapshot.os,
83
- libc: pkgSnapshot.libc,
84
- };
85
- // TODO: depPath is not the package ID. Should be fixed
86
- installable =
87
- opts.includeIncompatiblePackages ||
88
- packageIsInstallable(pkgSnapshot.id ?? depPath, pkg, {
89
- engineStrict: opts.engineStrict,
90
- lockfileDir: opts.lockfileDir,
91
- nodeVersion: opts.currentEngine.nodeVersion,
92
- optional: pkgSnapshot.optional === true,
93
- supportedArchitectures: opts.supportedArchitectures,
94
- }) !== false;
95
- if (!installable) {
96
- if (!ctx.pickedPackages[depPath] && pkgSnapshot.optional === true) {
97
- opts.skipped.add(depPath);
98
- }
99
- }
100
- else {
101
- opts.skipped.delete(depPath);
102
- }
191
+ // `visited` guarantees one pass per dep path, so a cached entry is
192
+ // released as soon as it is consumed rather than being retained until the
193
+ // whole walk ends.
194
+ const edges = ctx.edgesByDepPath.get(depPath) ?? nextDepEdges(ctx, pkgSnapshot, opts);
195
+ ctx.edgesByDepPath.delete(depPath);
196
+ for (const edge of edges) {
197
+ queue.push(edge.depPath);
103
198
  }
104
- ctx.pickedPackages[depPath] = pkgSnapshot;
105
- const { depPaths: nextRelDepPaths, importerIds: additionalImporterIds } = parseDepRefs(Object.entries({
106
- ...pkgSnapshot.dependencies,
107
- ...(opts.include.optionalDependencies
108
- ? pkgSnapshot.optionalDependencies
109
- : {}),
110
- }), ctx.lockfile);
111
- additionalImporterIds.forEach((importerId) => ctx.importerIdSet.add(importerId));
112
- nextRelDepPaths.push(...toImporterDepPaths(ctx.lockfile, additionalImporterIds, {
199
+ }
200
+ }
201
+ /** The outbound edges of a package, tagged with the optionality of each. */
202
+ function nextDepEdges(ctx, pkgSnapshot, opts) {
203
+ const { depEdges, importerIds } = parseDepRefs([
204
+ ...toDepRefs(pkgSnapshot.dependencies, false),
205
+ ...(opts.include.optionalDependencies ? toDepRefs(pkgSnapshot.optionalDependencies, true) : []),
206
+ ], ctx.lockfile);
207
+ for (const importerId of importerIds) {
208
+ ctx.importerIdSet.add(importerId);
209
+ }
210
+ return [
211
+ ...depEdges,
212
+ ...toImporterDepPaths(ctx.lockfile, importerIds, {
113
213
  include: opts.include,
114
214
  importerIdSet: ctx.importerIdSet,
115
215
  skipRuntimes: opts.skipRuntimes,
116
- }));
117
- pkgAllDeps(ctx, nextRelDepPaths, installable, opts);
118
- }
216
+ }),
217
+ ];
119
218
  }
120
219
  function toImporterDepPaths(lockfile, importerIds, opts) {
121
220
  const importerDeps = importerIds
122
221
  .map(importerId => lockfile.importers[importerId])
123
- .map(importer => ({
124
- ...(opts.include.dependencies ? importer.dependencies : {}),
125
- ...(opts.include.devDependencies ? importer.devDependencies : {}),
126
- ...(opts.include.optionalDependencies
127
- ? importer.optionalDependencies
128
- : {}),
129
- }))
130
- .map(Object.entries)
131
- .map(entries => opts.skipRuntimes ? entries.filter(([, ref]) => !ref.startsWith('runtime:')) : entries);
132
- let { depPaths, importerIds: nextImporterIds } = parseDepRefs(unnest(importerDeps), lockfile);
222
+ .map(importer => [
223
+ ...(opts.include.dependencies ? toDepRefs(importer.dependencies, false) : []),
224
+ ...(opts.include.devDependencies ? toDepRefs(importer.devDependencies, false) : []),
225
+ ...(opts.include.optionalDependencies ? toDepRefs(importer.optionalDependencies, true) : []),
226
+ ])
227
+ .map(refs => opts.skipRuntimes ? refs.filter(({ ref }) => !ref.startsWith('runtime:')) : refs);
228
+ let { depEdges, importerIds: nextImporterIds } = parseDepRefs(unnest(importerDeps), lockfile);
133
229
  if (!nextImporterIds.length) {
134
- return depPaths;
230
+ return depEdges;
135
231
  }
136
232
  nextImporterIds = nextImporterIds.filter(importerId => !opts.importerIdSet.has(importerId));
137
233
  for (const importerId of nextImporterIds) {
138
234
  opts.importerIdSet.add(importerId);
139
235
  }
140
236
  return [
141
- ...depPaths,
237
+ ...depEdges,
142
238
  ...toImporterDepPaths(lockfile, nextImporterIds, opts),
143
239
  ];
144
240
  }
145
- function parseDepRefs(refsByPkgNames, lockfile) {
241
+ function toDepRefs(refsByPkgNames, optional) {
242
+ if (refsByPkgNames == null)
243
+ return [];
244
+ return Object.entries(refsByPkgNames).map(([pkgName, ref]) => ({ pkgName, ref, optional }));
245
+ }
246
+ function parseDepRefs(depRefs, lockfile) {
146
247
  const acc = {
147
- depPaths: [],
248
+ depEdges: [],
148
249
  importerIds: [],
149
250
  };
150
- for (const [pkgName, ref] of refsByPkgNames) {
251
+ for (const { pkgName, ref, optional } of depRefs) {
151
252
  if (ref.startsWith('link:')) {
152
253
  const importerId = ref.substring(5);
153
254
  if (lockfile.importers[importerId]) {
@@ -158,7 +259,7 @@ function parseDepRefs(refsByPkgNames, lockfile) {
158
259
  const depPath = dp.refToRelative(ref, pkgName);
159
260
  if (depPath == null)
160
261
  continue;
161
- acc.depPaths.push(depPath);
262
+ acc.depEdges.push({ depPath, optional });
162
263
  }
163
264
  return acc;
164
265
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/lockfile.filtering",
3
- "version": "1100.1.14",
3
+ "version": "1100.2.1",
4
4
  "description": "Filters a lockfile",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -29,14 +29,14 @@
29
29
  "!*.map"
30
30
  ],
31
31
  "dependencies": {
32
- "@pnpm/config.package-is-installable": "1100.0.16",
32
+ "@pnpm/config.package-is-installable": "1100.1.1",
33
33
  "@pnpm/constants": "1100.0.1",
34
- "@pnpm/deps.path": "1100.0.11",
34
+ "@pnpm/deps.path": "1100.0.13",
35
35
  "@pnpm/error": "1100.1.0",
36
- "@pnpm/lockfile.types": "1100.0.16",
37
- "@pnpm/lockfile.utils": "1100.1.5",
38
- "@pnpm/lockfile.walker": "1100.0.16",
39
- "@pnpm/types": "1101.6.0",
36
+ "@pnpm/lockfile.types": "1100.0.18",
37
+ "@pnpm/lockfile.utils": "1100.1.7",
38
+ "@pnpm/lockfile.walker": "1100.0.18",
39
+ "@pnpm/types": "1101.8.0",
40
40
  "ramda": "npm:@pnpm/ramda@0.28.1"
41
41
  },
42
42
  "peerDependencies": {
@@ -44,9 +44,9 @@
44
44
  },
45
45
  "devDependencies": {
46
46
  "@jest/globals": "30.4.1",
47
- "@pnpm/lockfile.filtering": "1100.1.14",
47
+ "@pnpm/lockfile.filtering": "1100.2.1",
48
48
  "@pnpm/logger": "1100.0.0",
49
- "@types/ramda": "0.31.1",
49
+ "@types/ramda": "0.32.0",
50
50
  "detect-libc": "^2.1.2",
51
51
  "tempy": "3.0.0",
52
52
  "write-yaml-file": "^6.0.0",