@pnpm/installing.deps-installer 1101.1.1 → 1101.2.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.
@@ -5,7 +5,7 @@ import type { ProjectOptions } from '@pnpm/installing.context';
5
5
  import type { HoistingLimits } from '@pnpm/installing.deps-restorer';
6
6
  import type { IncludedDependencies } from '@pnpm/installing.modules-yaml';
7
7
  import type { LockfileObject } from '@pnpm/lockfile.fs';
8
- import type { WorkspacePackages } from '@pnpm/resolving.resolver-base';
8
+ import type { ResolutionPolicyViolation, ResolutionVerifier, WorkspacePackages } from '@pnpm/resolving.resolver-base';
9
9
  import type { StoreController } from '@pnpm/store.controller-types';
10
10
  import type { AllowedDeprecatedVersions, PackageExtension, PackageVulnerabilityAudit, PeerDependencyRules, ReadPackageHook, Registries, RegistryConfig, SupportedArchitectures, TrustPolicy } from '@pnpm/types';
11
11
  import type { ReporterFunction } from '../types.js';
@@ -148,6 +148,39 @@ export interface StrictInstallOptions {
148
148
  ci?: boolean;
149
149
  minimumReleaseAge?: number;
150
150
  minimumReleaseAgeExclude?: string[];
151
+ /**
152
+ * Resolver-agnostic post-tree gate, invoked between
153
+ * `resolveDependencyTree` and `resolvePeers` inside
154
+ * `resolveDependencies`. Receives the violations the verifier
155
+ * fan-out collected from the freshly-resolved tree. Throwing here
156
+ * unwinds the install before peer-dep resolution runs — nothing on
157
+ * disk has changed, and the (potentially expensive) peer pass is
158
+ * skipped on abort.
159
+ *
160
+ * Intentionally policy-neutral. Each verifier owns its violation
161
+ * codes (`MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`, …); the
162
+ * install command filters by code to decide what to do. Future
163
+ * resolvers can plug verifiers in without touching this signature.
164
+ */
165
+ handleResolutionPolicyViolations?: (violations: readonly ResolutionPolicyViolation[]) => Promise<void>;
166
+ /**
167
+ * Resolver-side verifiers that re-check each lockfile-pinned resolution
168
+ * against policies configured upstream (today: at most one,
169
+ * `npm.minimumReleaseAge` in strict mode). Constructed by `createClient`
170
+ * and surfaced via the `createStoreController` return; mutateModules
171
+ * fans out across the list once, right after the lockfile is loaded
172
+ * from disk. Empty when no policy is active.
173
+ */
174
+ resolutionVerifiers: ResolutionVerifier[];
175
+ /**
176
+ * pnpm's on-disk cache directory. When set together with non-empty
177
+ * `resolutionVerifiers`, the lockfile verification result is memoized
178
+ * in `<cacheDir>/lockfile-verified.jsonl` so repeat installs against an
179
+ * unchanged lockfile skip the per-package registry round trip. The
180
+ * record is policy-neutral; each active resolver-side verifier writes
181
+ * its own slot under `verifiers[<key>]`.
182
+ */
183
+ cacheDir?: string;
151
184
  trustPolicy?: TrustPolicy;
152
185
  trustPolicyExclude?: string[];
153
186
  trustPolicyIgnoreAfter?: number;
@@ -102,6 +102,7 @@ const defaults = (opts) => {
102
102
  peersSuffixMaxLength: 1000,
103
103
  blockExoticSubdeps: false,
104
104
  omitSummaryLog: false,
105
+ resolutionVerifiers: [],
105
106
  };
106
107
  };
107
108
  export function extendOptions(opts) {
@@ -2,7 +2,7 @@ import type { Catalogs } from '@pnpm/catalogs.types';
2
2
  import { PnpmError } from '@pnpm/error';
3
3
  import { type PinnedVersion, type UpdateMatchingFunction, type WantedDependency } from '@pnpm/installing.deps-resolver';
4
4
  import { type InstallationResultStats } from '@pnpm/installing.deps-restorer';
5
- import type { PreferredVersions } from '@pnpm/resolving.resolver-base';
5
+ import type { PreferredVersions, ResolutionPolicyViolation } from '@pnpm/resolving.resolver-base';
6
6
  import type { DependenciesField, DepPath, IgnoredBuilds, PeerDependencyIssues, ProjectId, ProjectManifest, ProjectRootDir, ReadPackageHook } from '@pnpm/types';
7
7
  import { type InstallOptions } from './extendInstallOptions.js';
8
8
  interface InstallMutationOptions {
@@ -45,6 +45,8 @@ export interface InstallResult {
45
45
  updatedCatalogs: Catalogs | undefined;
46
46
  updatedManifest: ProjectManifest;
47
47
  ignoredBuilds: IgnoredBuilds | undefined;
48
+ /** Forwarded from {@link MutateModulesResult.resolutionPolicyViolations}. */
49
+ resolutionPolicyViolations: ResolutionPolicyViolation[];
48
50
  }
49
51
  export declare function install(manifest: ProjectManifest, opts: Opts): Promise<InstallResult>;
50
52
  export type MutatedProject = DependenciesMutation & {
@@ -60,6 +62,8 @@ export interface MutateModulesInSingleProjectResult {
60
62
  updatedCatalogs: Catalogs | undefined;
61
63
  updatedProject: UpdatedProject;
62
64
  ignoredBuilds: IgnoredBuilds | undefined;
65
+ /** Forwarded from {@link MutateModulesResult.resolutionPolicyViolations}. */
66
+ resolutionPolicyViolations: ResolutionPolicyViolation[];
63
67
  }
64
68
  export declare function mutateModulesInSingleProject(project: MutatedProject & {
65
69
  binsDir?: string;
@@ -73,6 +77,15 @@ export interface MutateModulesResult {
73
77
  stats: InstallationResultStats;
74
78
  depsRequiringBuild?: DepPath[];
75
79
  ignoredBuilds: IgnoredBuilds | undefined;
80
+ /**
81
+ * Resolver-policy violations the post-resolution scan found in the
82
+ * freshly-resolved lockfile. Each violation carries a verifier code
83
+ * (e.g. `MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`); the
84
+ * install command filters by code to decide what to do (persist to
85
+ * `minimumReleaseAgeExclude`, log, etc.). Empty array when no
86
+ * verifier reported a violation or no policy was active.
87
+ */
88
+ resolutionPolicyViolations: ResolutionPolicyViolation[];
76
89
  }
77
90
  export declare function mutateModules(projects: MutatedProject[], maybeOpts: MutateModulesOptions): Promise<MutateModulesResult>;
78
91
  export declare function addDependenciesToPackage(manifest: ProjectManifest, dependencySelectors: string[], opts: Omit<InstallOptions, 'allProjects'> & {
@@ -15,7 +15,7 @@ import { getContext } from '@pnpm/installing.context';
15
15
  import { getWantedDependencies, resolveDependencies, } from '@pnpm/installing.deps-resolver';
16
16
  import { extendProjectsWithTargetDirs, headlessInstall } from '@pnpm/installing.deps-restorer';
17
17
  import { writeModulesManifest } from '@pnpm/installing.modules-yaml';
18
- import { cleanGitBranchLockfiles, readWantedLockfile, writeCurrentLockfile, writeLockfiles, writeWantedLockfile, } from '@pnpm/lockfile.fs';
18
+ import { cleanGitBranchLockfiles, getWantedLockfileName, readWantedLockfile, writeCurrentLockfile, writeLockfiles, writeWantedLockfile, } from '@pnpm/lockfile.fs';
19
19
  import { getPreferredVersionsFromLockfileAndManifests } from '@pnpm/lockfile.preferred-versions';
20
20
  import { calcPatchHashes, createOverridesMapFromParsed, getOutdatedLockfileSetting, } from '@pnpm/lockfile.settings-checker';
21
21
  import { writePnpFile } from '@pnpm/lockfile.to-pnp';
@@ -38,6 +38,9 @@ import { extendOptions, } from './extendInstallOptions.js';
38
38
  import { linkPackages } from './link.js';
39
39
  import { reportPeerDependencyIssues } from './reportPeerDependencyIssues.js';
40
40
  import { validateModules } from './validateModules.js';
41
+ import { verifyLockfileResolutions } from './verifyLockfileResolutions.js';
42
+ import { writeLockfilesAndRecordVerified } from './writeLockfilesAndRecordVerified.js';
43
+ import { writeWantedLockfileAndRecordVerified } from './writeWantedLockfileAndRecordVerified.js';
41
44
  class LockfileConfigMismatchError extends PnpmError {
42
45
  constructor(outdatedLockfileSettingName) {
43
46
  super('LOCKFILE_CONFIG_MISMATCH', `Cannot proceed with the frozen installation. The current "${outdatedLockfileSettingName}" configuration doesn't match the value found in the lockfile`, {
@@ -57,7 +60,7 @@ export async function install(manifest, opts) {
57
60
  if (opts.agent) {
58
61
  return installFromPnpmRegistry(manifest, rootDir, opts);
59
62
  }
60
- const { updatedCatalogs, updatedProjects: projects, ignoredBuilds } = await mutateModules([
63
+ const { updatedCatalogs, updatedProjects: projects, ignoredBuilds, resolutionPolicyViolations } = await mutateModules([
61
64
  {
62
65
  mutation: 'install',
63
66
  pruneDirectDependencies: opts.pruneDirectDependencies,
@@ -76,7 +79,7 @@ export async function install(manifest, opts) {
76
79
  binsDir: opts.binsDir,
77
80
  }],
78
81
  });
79
- return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds };
82
+ return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds, resolutionPolicyViolations };
80
83
  }
81
84
  export async function mutateModulesInSingleProject(project, maybeOpts) {
82
85
  const result = await mutateModules([
@@ -98,6 +101,7 @@ export async function mutateModulesInSingleProject(project, maybeOpts) {
98
101
  updatedCatalogs: result.updatedCatalogs,
99
102
  updatedProject: result.updatedProjects[0],
100
103
  ignoredBuilds: result.ignoredBuilds,
104
+ resolutionPolicyViolations: result.resolutionPolicyViolations,
101
105
  };
102
106
  }
103
107
  const pickCatalogSpecifier = {
@@ -107,6 +111,11 @@ const pickCatalogSpecifier = {
107
111
  };
108
112
  export async function mutateModules(projects, maybeOpts) {
109
113
  const reporter = maybeOpts?.reporter;
114
+ const detachReporter = (reporter != null) && typeof reporter === 'function'
115
+ ? () => {
116
+ streamParser.removeListener('data', reporter);
117
+ }
118
+ : () => { };
110
119
  if ((reporter != null) && typeof reporter === 'function') {
111
120
  streamParser.on('data', reporter);
112
121
  }
@@ -153,6 +162,36 @@ export async function mutateModules(projects, maybeOpts) {
153
162
  ctx = await getContext(opts);
154
163
  }
155
164
  }
165
+ // Re-validate every entry in the lockfile against the policies the
166
+ // resolver chain was built with (today: minimumReleaseAge in strict mode
167
+ // via the npm verifier; the abstraction supports other resolvers
168
+ // attaching their own verifiers). The threat model is a lockfile that
169
+ // someone else resolved — committed to the repo, restored from a CI
170
+ // cache, etc. — bypassing the local resolver's policy filters; the local
171
+ // resolver's own filters already cover fresh resolution. We run this
172
+ // exactly once, right after the lockfile is loaded from disk, before any
173
+ // path branches.
174
+ const cacheActive = opts.cacheDir != null && opts.resolutionVerifiers.length > 0;
175
+ const wantedLockfilePath = cacheActive
176
+ ? path.resolve(ctx.lockfileDir, await getWantedLockfileName({
177
+ useGitBranchLockfile: opts.useGitBranchLockfile,
178
+ mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
179
+ }))
180
+ : undefined;
181
+ try {
182
+ await verifyLockfileResolutions(ctx.wantedLockfile, opts.resolutionVerifiers, {
183
+ cacheDir: opts.cacheDir,
184
+ lockfilePath: wantedLockfilePath,
185
+ });
186
+ }
187
+ catch (err) {
188
+ // verifyLockfileResolutions is the one throw site in this function
189
+ // that's part of normal user-facing operation (a rejected lockfile);
190
+ // other throws here are unexpected. Detach the reporter listener so
191
+ // long-lived processes don't leak it on every rejected install.
192
+ detachReporter();
193
+ throw err;
194
+ }
156
195
  if (opts.hooks.preResolution) {
157
196
  for (const preResolution of opts.hooks.preResolution) {
158
197
  // eslint-disable-next-line no-await-in-loop
@@ -227,15 +266,14 @@ export async function mutateModules(projects, maybeOpts) {
227
266
  ignoredScriptsLogger.debug({
228
267
  packageNames: ignoredBuilds ? dedupePackageNamesFromIgnoredBuilds(ignoredBuilds) : [],
229
268
  });
230
- if ((reporter != null) && typeof reporter === 'function') {
231
- streamParser.removeListener('data', reporter);
232
- }
269
+ detachReporter();
233
270
  return {
234
271
  updatedCatalogs: result.updatedCatalogs,
235
272
  updatedProjects: result.updatedProjects,
236
273
  stats: result.stats ?? { added: 0, removed: 0, linkedToRoot: 0 },
237
274
  depsRequiringBuild: result.depsRequiringBuild,
238
275
  ignoredBuilds,
276
+ resolutionPolicyViolations: result.resolutionPolicyViolations ?? [],
239
277
  };
240
278
  async function _install() {
241
279
  const scriptsOpts = {
@@ -472,8 +510,9 @@ export async function mutateModules(projects, maybeOpts) {
472
510
  defaultCatalog: opts.catalogs?.default,
473
511
  });
474
512
  if (opts.catalogMode !== 'manual') {
475
- const catalogBareSpecifier = `catalog:${opts.saveCatalogName == null || opts.saveCatalogName === 'default' ? '' : opts.saveCatalogName}`;
476
513
  for (const wantedDep of wantedDeps) {
514
+ const perDepCatalogName = getPerDepCatalogName(wantedDep, opts.saveCatalogName);
515
+ const catalogBareSpecifier = `catalog:${perDepCatalogName === 'default' ? '' : perDepCatalogName}`;
477
516
  const catalog = resolveFromCatalog(opts.catalogs, { ...wantedDep, bareSpecifier: catalogBareSpecifier });
478
517
  const catalogDepSpecifier = matchCatalogResolveResult(catalog, pickCatalogSpecifier);
479
518
  if (!catalogDepSpecifier ||
@@ -481,7 +520,7 @@ export async function mutateModules(projects, maybeOpts) {
481
520
  semver.validRange(wantedDep.bareSpecifier) &&
482
521
  semver.validRange(catalogDepSpecifier) &&
483
522
  semver.eq(wantedDep.bareSpecifier, catalogDepSpecifier)) {
484
- wantedDep.saveCatalogName = opts.saveCatalogName ?? 'default';
523
+ wantedDep.saveCatalogName = perDepCatalogName;
485
524
  continue;
486
525
  }
487
526
  switch (opts.catalogMode) {
@@ -522,6 +561,7 @@ export async function mutateModules(projects, maybeOpts) {
522
561
  stats: result.stats,
523
562
  depsRequiringBuild: result.depsRequiringBuild,
524
563
  ignoredBuilds: result.ignoredBuilds,
564
+ resolutionPolicyViolations: result.resolutionPolicyViolations,
525
565
  };
526
566
  }
527
567
  /**
@@ -791,9 +831,26 @@ function isWantedDepBareSpecifierSame(prevCatalogs, catalogsConfig, alias, prevB
791
831
  const nextCatalogEntrySpec = catalogsConfig?.[catalogName]?.[alias];
792
832
  return prevCatalogEntrySpec === nextCatalogEntrySpec;
793
833
  }
834
+ /**
835
+ * Determines the catalog name for a dependency during installSome.
836
+ *
837
+ * If the dependency's previous specifier already uses a named catalog
838
+ * (e.g. "catalog:foo"), that catalog name takes priority over the global
839
+ * saveCatalogName option. This ensures that interactive updates and
840
+ * `--latest` upgrades preserve the per-dependency catalog group.
841
+ */
842
+ function getPerDepCatalogName(wantedDep, globalSaveCatalogName) {
843
+ if (wantedDep.prevSpecifier) {
844
+ const catalogFromPrev = parseCatalogProtocol(wantedDep.prevSpecifier);
845
+ if (catalogFromPrev != null) {
846
+ return catalogFromPrev;
847
+ }
848
+ }
849
+ return globalSaveCatalogName ?? 'default';
850
+ }
794
851
  export async function addDependenciesToPackage(manifest, dependencySelectors, opts) {
795
852
  const rootDir = (opts.dir ?? process.cwd());
796
- const { updatedCatalogs, updatedProjects: projects, ignoredBuilds } = await mutateModules([
853
+ const { updatedCatalogs, updatedProjects: projects, ignoredBuilds, resolutionPolicyViolations } = await mutateModules([
797
854
  {
798
855
  allowNew: opts.allowNew,
799
856
  dependencySelectors,
@@ -819,7 +876,7 @@ export async function addDependenciesToPackage(manifest, dependencySelectors, op
819
876
  },
820
877
  ],
821
878
  });
822
- return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds };
879
+ return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds, resolutionPolicyViolations };
823
880
  }
824
881
  const _installInContext = async (projects, ctx, opts) => {
825
882
  // The wanted lockfile is mutated during installation. To compare changes, a
@@ -892,7 +949,7 @@ const _installInContext = async (projects, ctx, opts) => {
892
949
  // ignores preferred versions from the lockfile.
893
950
  forgetResolutionsOfAllPrevWantedDeps(ctx.wantedLockfile);
894
951
  }
895
- let { dependenciesGraph, dependenciesByProjectId, linkedDependenciesByProjectId, updatedCatalogs, newLockfile, outdatedDependencies, peerDependencyIssuesByProjects, wantedToBeSkippedPackageIds, waitTillAllFetchingsFinish, } = await resolveDependencies(projects, {
952
+ let { dependenciesGraph, dependenciesByProjectId, linkedDependenciesByProjectId, updatedCatalogs, newLockfile, outdatedDependencies, peerDependencyIssuesByProjects, wantedToBeSkippedPackageIds, waitTillAllFetchingsFinish, resolutionPolicyViolations, } = await resolveDependencies(projects, {
896
953
  allowBuild: opts.allowBuild,
897
954
  allowedDeprecatedVersions: opts.allowedDeprecatedVersions,
898
955
  allowUnusedPatches: opts.allowUnusedPatches,
@@ -946,6 +1003,7 @@ const _installInContext = async (projects, ctx, opts) => {
946
1003
  trustPolicyIgnoreAfter: opts.trustPolicyIgnoreAfter,
947
1004
  blockExoticSubdeps: opts.blockExoticSubdeps,
948
1005
  allProjectIds: Object.values(ctx.projects).map((p) => p.id),
1006
+ handleResolutionPolicyViolations: opts.handleResolutionPolicyViolations,
949
1007
  });
950
1008
  if (!opts.include.optionalDependencies || !opts.include.devDependencies || !opts.include.dependencies) {
951
1009
  linkedDependenciesByProjectId = mapValues((linkedDeps) => linkedDeps.filter((linkedDep) => !(linkedDep.dev && !opts.include.devDependencies ||
@@ -1160,11 +1218,13 @@ const _installInContext = async (projects, ctx, opts) => {
1160
1218
  const currentLockfileDir = path.join(ctx.rootModulesDir, '.pnpm');
1161
1219
  await Promise.all([
1162
1220
  opts.useLockfile && opts.saveLockfile
1163
- ? writeLockfiles({
1221
+ ? writeLockfilesAndRecordVerified({
1164
1222
  currentLockfile: result.currentLockfile,
1165
1223
  currentLockfileDir,
1166
1224
  wantedLockfile: newLockfile,
1167
1225
  wantedLockfileDir: ctx.lockfileDir,
1226
+ cacheDir: opts.cacheDir,
1227
+ resolutionVerifiers: opts.resolutionVerifiers,
1168
1228
  ...lockfileOpts,
1169
1229
  })
1170
1230
  : writeCurrentLockfile(ctx.virtualStoreDir, result.currentLockfile),
@@ -1216,7 +1276,13 @@ const _installInContext = async (projects, ctx, opts) => {
1216
1276
  }
1217
1277
  else {
1218
1278
  if (opts.useLockfile && opts.saveLockfile && !isInstallationOnlyForLockfileCheck) {
1219
- await writeWantedLockfile(ctx.lockfileDir, newLockfile, lockfileOpts);
1279
+ await writeWantedLockfileAndRecordVerified({
1280
+ lockfileDir: ctx.lockfileDir,
1281
+ lockfile: newLockfile,
1282
+ cacheDir: opts.cacheDir,
1283
+ resolutionVerifiers: opts.resolutionVerifiers,
1284
+ ...lockfileOpts,
1285
+ });
1220
1286
  }
1221
1287
  if (opts.nodeLinker !== 'hoisted') {
1222
1288
  // This is only needed because otherwise the reporter will hang
@@ -1264,6 +1330,7 @@ const _installInContext = async (projects, ctx, opts) => {
1264
1330
  stats,
1265
1331
  depsRequiringBuild,
1266
1332
  ignoredBuilds,
1333
+ resolutionPolicyViolations,
1267
1334
  };
1268
1335
  };
1269
1336
  function allMutationsAreInstalls(projects) {
@@ -1627,6 +1694,14 @@ async function mutateModulesViaAgent(projects, opts) {
1627
1694
  * packages into node_modules.
1628
1695
  */
1629
1696
  async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjects) {
1697
+ // The agent path skips client-side resolution, so resolver-side policies
1698
+ // can't be enforced locally. `minimumReleaseAge` is forwarded to the
1699
+ // agent and enforced server-side. `trustPolicy` has no server-side
1700
+ // counterpart yet, so refuse to run under it instead of silently
1701
+ // letting through a lockfile the local verifier would reject.
1702
+ if (opts.trustPolicy === 'no-downgrade') {
1703
+ throw new PnpmError('TRUST_POLICY_INCOMPATIBLE_WITH_AGENT', 'The pnpm agent does not yet enforce `trustPolicy: no-downgrade`, so running an install through the agent under this policy would produce a lockfile that the local verifier rejects.', { hint: 'Unset `trustPolicy` for this install, or disable the agent (unset `--agent` / `agent` in pnpm-workspace.yaml) so resolution runs locally and the trust check applies.' });
1704
+ }
1630
1705
  const { fetchFromPnpmRegistry } = await import('@pnpm/agent.client');
1631
1706
  const { StoreIndex } = await import('@pnpm/store.index');
1632
1707
  const { setImportConcurrency } = await import('@pnpm/worker');
@@ -1677,7 +1752,14 @@ async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjec
1677
1752
  finally {
1678
1753
  storeIndex.close();
1679
1754
  }
1680
- await writeWantedLockfile(lockfileDir, lockfile);
1755
+ await writeWantedLockfileAndRecordVerified({
1756
+ lockfileDir,
1757
+ lockfile,
1758
+ cacheDir: opts.cacheDir,
1759
+ resolutionVerifiers: opts.resolutionVerifiers,
1760
+ useGitBranchLockfile: opts.useGitBranchLockfile,
1761
+ mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
1762
+ });
1681
1763
  logger.info({
1682
1764
  message: `Resolved ${agentStats.totalPackages} packages: ${agentStats.alreadyInStore} cached, ${agentStats.filesToDownload} files to download`,
1683
1765
  prefix: rootDir,
@@ -1757,6 +1839,12 @@ async function installFromPnpmRegistry(manifest, rootDir, opts, allInstallProjec
1757
1839
  ignoredBuilds,
1758
1840
  stats,
1759
1841
  lockfile,
1842
+ // Server-side resolution (pnpm agent) enforces `minimumReleaseAge`
1843
+ // itself — the agent picks only mature versions and the lockfile
1844
+ // can't contain immature entries to auto-collect. `trustPolicy` is
1845
+ // guarded above (we refuse to enter this path when it's set), so
1846
+ // there's nothing for the install command to react to here.
1847
+ resolutionPolicyViolations: [],
1760
1848
  };
1761
1849
  }
1762
1850
  finally {
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { progressLogger, stageLogger, statsLogger, } from '@pnpm/core-loggers';
4
- import { calcDepState } from '@pnpm/deps.graph-hasher';
4
+ import { calcDepState, findRuntimeNodeVersion } from '@pnpm/deps.graph-hasher';
5
5
  import { symlinkDependency } from '@pnpm/fs.symlink-dependency';
6
6
  import { linkDirectDeps } from '@pnpm/installing.linking.direct-dep-linker';
7
7
  import { hoist } from '@pnpm/installing.linking.hoist';
@@ -318,6 +318,11 @@ async function selectNewFromWantedDeps(wantedRelDepPaths, currentLockfile, depGr
318
318
  }
319
319
  const limitLinking = pLimit(16);
320
320
  async function linkAllPkgs(storeController, depNodes, opts) {
321
+ // Resolved `engines.runtime` Node version (when present) so the
322
+ // side-effects-cache key prefix tracks the script-runner Node
323
+ // rather than pnpm's own `process.version`. Computed once outside
324
+ // the per-node loop.
325
+ const nodeVersion = findRuntimeNodeVersion(Object.keys(opts.depGraph));
321
326
  await Promise.all(depNodes.map(async (depNode) => {
322
327
  const { files } = await depNode.fetching();
323
328
  depNode.requiresBuild = files.requiresBuild;
@@ -328,6 +333,7 @@ async function linkAllPkgs(storeController, depNodes, opts) {
328
333
  includeDepGraphHash: !opts.ignoreScripts && depNode.requiresBuild, // true when is built
329
334
  patchFileHash: depNode.patch?.hash,
330
335
  supportedArchitectures: opts.supportedArchitectures,
336
+ nodeVersion,
331
337
  });
332
338
  }
333
339
  }
@@ -0,0 +1,22 @@
1
+ import type { LockfileObject } from '@pnpm/lockfile.fs';
2
+ import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base';
3
+ export interface RecordLockfileVerifiedOptions {
4
+ cacheDir?: string;
5
+ /** Absolute path of the lockfile the next install will read.
6
+ * Under `useGitBranchLockfile` this is the branch-suffixed name. */
7
+ lockfilePath: string;
8
+ /** The writer's canonical return value — see {@link writeWantedLockfile}.
9
+ * Passing the raw in-memory write object would record a hash the
10
+ * next install can't match (YAML drops undefined fields). */
11
+ lockfile: LockfileObject;
12
+ resolutionVerifiers: readonly ResolutionVerifier[] | undefined;
13
+ }
14
+ /**
15
+ * Records the post-resolution lockfile as verified so the next install
16
+ * skips the registry round-trip. Skipping is safe: fresh local picks
17
+ * are filtered by the resolver (see
18
+ * `resolving/npm-resolver/src/pickPackage.ts`) and carried-over entries
19
+ * already passed the gate at the top of `mutateModules`, so the
20
+ * recorded lockfile is policy-clean by construction.
21
+ */
22
+ export declare function recordLockfileVerified(opts: RecordLockfileVerifiedOptions): void;
@@ -0,0 +1,24 @@
1
+ import { hashObject } from '@pnpm/crypto.object-hasher';
2
+ import { recordVerification } from './verifyLockfileResolutionsCache.js';
3
+ /**
4
+ * Records the post-resolution lockfile as verified so the next install
5
+ * skips the registry round-trip. Skipping is safe: fresh local picks
6
+ * are filtered by the resolver (see
7
+ * `resolving/npm-resolver/src/pickPackage.ts`) and carried-over entries
8
+ * already passed the gate at the top of `mutateModules`, so the
9
+ * recorded lockfile is policy-clean by construction.
10
+ */
11
+ export function recordLockfileVerified(opts) {
12
+ if (!opts.cacheDir)
13
+ return;
14
+ if (!opts.resolutionVerifiers?.length)
15
+ return;
16
+ if (!opts.lockfile.packages)
17
+ return;
18
+ recordVerification(opts.cacheDir, {
19
+ lockfilePath: opts.lockfilePath,
20
+ verifiers: opts.resolutionVerifiers,
21
+ hashLockfile: () => hashObject(opts.lockfile),
22
+ });
23
+ }
24
+ //# sourceMappingURL=recordLockfileVerified.js.map
@@ -0,0 +1,59 @@
1
+ import type { LockfileObject } from '@pnpm/lockfile.fs';
2
+ import type { ResolutionPolicyViolation, ResolutionVerifier } from '@pnpm/resolving.resolver-base';
3
+ export type { ResolutionPolicyViolation };
4
+ export interface VerifyLockfileResolutionsOptions {
5
+ concurrency?: number;
6
+ /**
7
+ * pnpm's on-disk cache directory. When set together with
8
+ * `lockfilePath`, verification results are memoized in
9
+ * `<cacheDir>/lockfile-verified.jsonl` and the gate short-circuits on
10
+ * a repeat run against an unchanged lockfile + same-or-stricter
11
+ * policy. Omit to disable the cache entirely (every call rehashes
12
+ * the lockfile and re-verifies).
13
+ */
14
+ cacheDir?: string;
15
+ /** Absolute path of the lockfile being verified. Used by the cache's stat shortcut. */
16
+ lockfilePath?: string;
17
+ }
18
+ /**
19
+ * Policy-neutral pass that asks every resolver-supplied
20
+ * {@link ResolutionVerifier} to check every entry in a lockfile loaded
21
+ * from disk. Iteration runs before resolution decisions are touched and
22
+ * before any tarball is fetched, so a lockfile whose entries were
23
+ * resolved elsewhere (committed to the repo, restored from a cache,
24
+ * etc.) under a weaker or absent policy cannot reach the filesystem.
25
+ * Fresh local resolution is covered by the resolver's own per-version
26
+ * filter.
27
+ *
28
+ * Each verifier handles its own protocol short-circuit inside `verify`
29
+ * (returning `{ ok: true }` for resolutions outside its scope), so the
30
+ * fan-out is policy-neutral and dispatch-free at this layer.
31
+ *
32
+ * Designed for fail-closed semantics at the verifier level: a verifier
33
+ * that can't confirm a resolution is expected to return `{ ok: false }`
34
+ * rather than passing silently — otherwise a registry hiccup or an
35
+ * unpublished version would re-open the bypass.
36
+ *
37
+ * No-op when `verifiers` is empty.
38
+ *
39
+ * When `options.cacheDir` and `options.lockfilePath` are both
40
+ * provided, an unchanged lockfile that has already been verified
41
+ * under the same (or stricter) policy short-circuits the registry
42
+ * round-trip entirely — see {@link tryLockfileVerificationCache} for
43
+ * the lookup logic.
44
+ */
45
+ export declare function verifyLockfileResolutions(lockfile: LockfileObject, verifiers: ResolutionVerifier[], options?: VerifyLockfileResolutionsOptions): Promise<void>;
46
+ /**
47
+ * Collect-mode sibling of {@link verifyLockfileResolutions}: runs the
48
+ * same fan-out over every verifier and every lockfile entry, but
49
+ * returns the violations as data instead of throwing on the first batch.
50
+ * No cache lookup or write — the throw-mode `verifyLockfileResolutions`
51
+ * is what populates / honors the cache; this is for callers that need
52
+ * to inspect violations (auto-collect into `minimumReleaseAgeExclude`,
53
+ * the strict-mode interactive prompt, future resolver-specific
54
+ * policies).
55
+ *
56
+ * Returns an empty array when `verifiers` is empty or the lockfile has
57
+ * no packages, so callers don't need a separate emptiness check.
58
+ */
59
+ export declare function collectResolutionPolicyViolations(lockfile: LockfileObject, verifiers: ResolutionVerifier[], options?: Pick<VerifyLockfileResolutionsOptions, 'concurrency'>): Promise<ResolutionPolicyViolation[]>;