Three coordinated changes that close the silent-bypass gap in loose `minimumReleaseAge` mode AND the discover-by-loop UX problem in strict mode (#10488), plus a parallel hardening of the lockfile verifier:
1. **Auto-collect into `minimumReleaseAgeExclude` (loose mode)** — fresh resolutions that fall back to a version newer than the cutoff are auto-recorded into the workspace manifest's `minimumReleaseAgeExclude`. A single info message lists what was persisted. The workspace manifest writer dedupes against existing entries.
2. **Lockfile verifier runs in loose mode too** — `createNpmResolutionVerifier` no longer gates on `minimumReleaseAgeStrict`. With auto-collect keeping the exclude list explicit, every accepted-immature pin must be on the list — same contract strict mode enforces. Lockfiles produced under a weaker (or absent) policy that still hold immature entries are rejected the same way strict mode would.
3. **Strict mode prompts on the aggregate set instead of throwing on the first** — the resolver always collects every immature direct and transitive in one pass; the install command's `handleResolutionPolicyViolations` checkpoint decides what to do with the set. Interactive (TTY) prompts the user once with the full list (default = No) and asks whether to add them all to `minimumReleaseAgeExclude` and proceed. Approve → install continues, persisted at the end. Decline → resolution aborts before the lockfile, package.json, or modules dir is touched. Non-interactive (CI) keeps `ERR_PNPM_NO_MATURE_MATCHING_VERSION` as the exit code but lists every offending entry instead of just the first one the resolver happened to hit.
4. **The lockfile verifier now also covers `trustPolicy: 'no-downgrade'`.** The same post-resolution gate that re-checks `minimumReleaseAge` on lockfile entries now re-runs `failIfTrustDowngraded` for every npm-registry entry whose name isn't on `trustPolicyExclude`. The two checks share a single full-metadata fetch per package, so the extra coverage doesn't cost an extra round trip when both policies are active. Resolver-time trust checks still run as before — this just closes the gap when an entry bypasses resolution (peek path, `--frozen-lockfile`, restored CI cache).
The steady-state flows:
- **Loose mode, `pnpm add foo@immature`**: lockfile clean, verifier no-op, resolver picks via lowest-version fallback, `foo@immature` lands in `minimumReleaseAgeExclude`, install succeeds. Subsequent `pnpm install --frozen-lockfile` in CI verifies against the populated list and succeeds.
- **Strict mode (interactive), security bump to `next@15.5.9`**: resolver collects `next@15.5.9` AND every immature `@next/swc-*@15.5.9` shim. pnpm prompts once with the full list. User approves → install completes, all entries persisted in `pnpm-workspace.yaml`. CI then runs the populated config cleanly.
- **Strict mode (non-interactive / CI)**: aborts with `ERR_PNPM_NO_MATURE_MATCHING_VERSION` listing every immature entry's `name@version` and publish time — no more discover-by-loop dance.
- **Teammate commits a poisoned lockfile**: single-policy batches reject with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` (or `ERR_PNPM_TRUST_DOWNGRADE`); a batch that trips both policies escalates to the generic `ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION` and lists each entry's per-policy code in the breakdown.
### Implementation
- The npm resolver always falls back to the lowest matching version when no mature version satisfies the range, and flags the result with `ResolveResult.policyViolation` instead of throwing `NO_MATURE_MATCHING_VERSION`. `deferImmatureDecision` and `strictPublishedByCheck` are gone — every caller (install, dlx, outdated, self-update) inspects the violation and decides what to do.
- `policyViolation` flows from `ResolveResult` → `PackageResponse.body.policyViolation` → a shared accumulator in `ResolutionContext` → the `resolutionPolicyViolations` field on `resolveDependencyTree`'s return → out through `mutateModules` / `addDependenciesToPackage` to the install command.
- The violation type lives in `@pnpm/resolving.resolver-base` as `ResolutionPolicyViolation`; the npm resolver exports the two built-in codes (`MINIMUM_RELEASE_AGE_VIOLATION_CODE`, `TRUST_DOWNGRADE_VIOLATION_CODE`) as constants so consumers reference one source of truth.
- `handleResolutionPolicyViolations` runs between `resolveDependencyTree` and `resolvePeers` — the resolver-agnostic checkpoint where the install command's plan prompts (TTY) or aborts (no-TTY) with the full violation list.
- `setupPolicyHandlers` (in `installing/commands/src/policyHandlers.ts`) composes per-policy handlers behind a uniform plan interface: each handler has its own `handleResolutionPolicyViolations` (filter by code, decide what to do) and `pickManifestUpdates` (return a typed `WorkspaceManifestPolicyUpdates` patch the install command spreads into `updateWorkspaceManifest`). Today the only registered handler is `createMinimumReleaseAgeHandler` — strict + TTY prompts via `enquirer`, strict no-TTY throws `ERR_PNPM_NO_MATURE_MATCHING_VERSION` with every entry listed, loose mode auto-persists at the tail. Strict + `--no-save` is rejected up-front via `ERR_PNPM_STRICT_MIN_RELEASE_AGE_REQUIRES_SAVE`. Future policies plug in via a sibling factory + push into the handlers list, with no changes to `installDeps.ts` / `recursive.ts`.
- `installDeps` / `recursive` drain `pickManifestUpdates` after install and spread the patch into `updateWorkspaceManifest`. Plain `pnpm install` (no `--update`, no params) now still updates the workspace manifest when any handler contributes a patch. The `install` command's CLI schema gained `save: Boolean` so `--no-save` actually flows through to `opts.save = false` instead of being silently dropped by nopt.
- `makeResolutionStrict` (in `installing/client`) wraps a `ResolveFunction` and rethrows any `policyViolation` as a `PnpmError`. Used by `dlx` and `self-update` under strict `minimumReleaseAge` OR `trustPolicy: 'no-downgrade'`, since one-shot callers have nowhere to defer a violation to. Violation-code → error-code mapping lives in one place so future violation kinds get consistent UX.
- `createNpmResolutionVerifier` extends its check to `trustPolicy: 'no-downgrade'` — same per-entry fan-out, same cache key, sharing the full-metadata fetch with the maturity check. Trust-fetch errors now propagate up so the violation reason carries the underlying message (network code, 404 detail) instead of a generic "metadata is unavailable".
- `verifyLockfileResolutions`'s aggregate throw uses the per-policy code when every violation in the batch shares it, and escalates to a generic `LOCKFILE_RESOLUTION_VERIFICATION` (with per-entry codes in the breakdown) for mixed batches.
- The pnpm agent path refuses installs under `trustPolicy: 'no-downgrade'` (`ERR_PNPM_TRUST_POLICY_INCOMPATIBLE_WITH_AGENT`) — the agent has no server-side counterpart to that check yet, so silently allowing it would land a lockfile the local verifier would later reject. `minimumReleaseAge` is forwarded to the agent and enforced server-side, so that combination is fine.
### Pacquet parity
Pacquet only carries a stub reference to `minimumReleaseAgeExclude` (see `pacquet/crates/package-manager/src/version_policy.rs`); the broader `minimumReleaseAge` and `trustPolicy` policies aren't ported yet, so this feature is outside pacquet's current surface area. It'll come along when pacquet ports the policies.
### Closes
- Closes#10488 (resolves the discover-by-loop dance for security bumps without needing `withTransitives`).
The errorHandler test runs pnpm from this fixture directory. Without its
own workspace manifest, pnpm walked up and discovered the monorepo root,
triggering verifyDepsBeforeRun which modified unrelated files.
* refactor: rename workspace.sort-packages and workspace.pkgs-graph
- workspace.sort-packages -> workspace.projects-sorter
- workspace.pkgs-graph -> workspace.projects-graph
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: rename packages/ to core/ and pkg-manifest.read-package-json to reader
- Rename packages/ directory to core/ for clarity
- Rename pkg-manifest/read-package-json to pkg-manifest/reader (@pnpm/pkg-manifest.reader)
- Update all tsconfig, package.json, and lockfile references
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: consolidate runtime resolvers under engine/runtime domain
- Remove unused @pnpm/engine.runtime.node.fetcher package
- Rename engine/runtime/node.resolver to node-resolver (dash convention)
- Move resolving/bun-resolver to engine/runtime/bun-resolver
- Move resolving/deno-resolver to engine/runtime/deno-resolver
- Update all package names, tsconfig paths, and lockfile references
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: update lockfile after removing node.fetcher
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: sort tsconfig references and package.json deps alphabetically
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: auto-fix import sorting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: update __typings__ paths in tsconfig.lint.json for moved resolvers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: remove deno-resolver from deps of bun-resolver
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* chore: set up git-lfs hooks
* ci: checkout lfs files on CI
According to https://github.com/actions/checkout, checkout out LFS files
defaults to false.
* chore: track .tgz files in Git LFS
* test: use sha512 integrity in fixtures to fix ci break
* test: update snapshots for sha512 fixture change
* chore(deps): remove unneeded peer dependency exception
The `peerDependencyRules.allowedVersions` exception on
`@typescript-eslint/eslint-plugin` no longer seems to be necessary.
Removing it does not introduce any new peer dependency errors on
`pnpm install`.
I suspect this was needed for the
`eslint-config-standard-with-typescript` dependency in the past, but a
@typescript-eslint/eslint-plugin upgrade made it no longer necessary.
* chore(deps): update @typescript-eslint dependencies 5.62.0 -> 6.5.0
@typescript-eslint 6.5.0 is the first version to introduce support for
TypeScript 5.2.
https://github.com/typescript-eslint/typescript-eslint/releases/tag/v6.5.0
```
=============
WARNING: You are currently running a version of TypeScript which is not officially supported by @typescript-eslint/typescript-estree.
You may find that it works just fine, or you may not.
SUPPORTED TYPESCRIPT VERSIONS: >=3.3.1 <5.2.0
YOUR TYPESCRIPT VERSION: 5.2.2
Please only submit bug reports when using the officially supported version.
```
* chore(deps): update eslint-config-standard-with-typescript 37.0.0 -> 39.0.0
Version 38.0.0 is the first version to support @typescript-eslint v6.
https://github.com/standard/eslint-config-standard-with-typescript/releases/tag/v38.0.0
Otherwise the following error appears.
```
> eslint "src/**/*.ts" "test/**/*.ts" "--fix"
Oops! Something went wrong! :(
ESLint: 8.47.0
Error: ../../.eslintrc.json » @pnpm/eslint-config » eslint-config-standard-with-typescript:
Configuration for rule "@typescript-eslint/restrict-plus-operands" is invalid:
Value {"checkCompoundAssignments":true} should NOT have additional properties.
at ConfigValidator.validateRuleOptions (/Users/gluxon/Developer/pnpm/node_modules/.pnpm/@eslint+eslintrc@2.1.2/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2039:23)
at /Users/gluxon/Developer/pnpm/node_modules/.pnpm/@eslint+eslintrc@2.1.2/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2094:18
```
* chore: remove unnecessary disables for restrict-template-expressions
The `@typescript-eslint/restrict-template-expressions` rule relaxed
what types are allowed in template expressions.
c13ce0b4f7 (diff-b852e1e199d2976eee1183fc84ac12a5d42fc61f0ae4b1c290dd54d621546db0)
Many of these disables were for interpolated values that had an `any`
type.
* chore: remove unnecessary disables for restrict-plus-operands
The original error was:
```
Invalid operand for a '+' operation. Operands must each be a number or string. Got `any`. eslint@typescript-eslint/restrict-plus-operands
```
It look like the newer version now allows `any`.
* style: fix errors of prefer-optional-chain and prefer-nullish-coalescing
The `@typescript-eslint/prefer-optional-chain` and
`@typescript-eslint/prefer-nullish-coalescing` rules got a bit
smarter. This commit applies autofixes. I believe the changes should be
equivalent to what existed before.
Example of the new `@typescript-eslint/prefer-optional-chain` lints.
```
pnpm/pkg-manifest/exportable-manifest/src/index.ts
71:10 error Prefer using an optional chain expression instead, as it's more concise and easier to read @typescript-eslint/prefer-optional-chain
87:10 error Prefer using an optional chain expression instead, as it's more concise and easier to read @typescript-eslint/prefer-optional-chain
```
Example of the new `@typescript-eslint/prefer-nullish-coalescing` lints.
```
pnpm/fs/find-packages/src/index.ts
32:38 error Prefer using nullish coalescing operator (`??`) instead of a ternary expression, as it is simpler to read @typescript-eslint/prefer-nullish-coalescing
```
* chore(deps): update TypeScript 5.1.6 -> 5.2.2
* chore(deps): update @yarnpkg/core->@types/lodash override to 4.14.197
This fixes a compilation error that appears on TypeScript 5.2.2. This
error was fixed in a later version of `@types/lodash`.
https://github.com/DefinitelyTyped/DefinitelyTyped/pull/66123
```
../node_modules/.pnpm/@types+lodash@4.14.181/node_modules/@types/lodash/index.d.ts:45:15 - error TS2428: All declarations of 'WeakMap' must have identical type parameters.
45 interface WeakMap<K extends object, V> { }
~~~~~~~
Found 4 errors.
```
* auto-install-peers=true
* save-workspace-protocol=rolling
* publishConfig.linkDirectory true by default
* resolve-peers-from-workspace-root is true by default
* set dedupeDirectDeps to true by default in @pnpm/core.
* fix: get correct path info in dependencies hierarchy tree
* chore: test case for build dependencies hierarchy
* docs: update changeset
* chore: test cases
* fix: show full path in dependencies hierarchy tree
* refactor(dependencies-hierarchy): remove keypath argument from getTree
The `keypath` argument is an internal implementation detail of `getTree`
used to detect cycles in the package graph. Removing this from the call
signature of `getTree` since it exposes an implementation detail.
The start of a `getTree` call always passed in the starting node as the
initial value anyway.
```ts
const getChildrenTree = getTree.bind(null, { ... })
getChildrenTree([relativeId], relativeId)
```
It's simpler for that to happen in the first call to `getTreeHelper`
internally and better ensures the keypath is created correctly. A future
refactor makes construction of the keypath more involved.
* refactor(dependencies-hierarchy): remove refToRelative call in getPkgInfo
This removes an extra `refToRelative` call in `getPkgInfo`. The result
of this call wasn't used within the function and was simply passed back
to the caller.
Callers of `getPkgInfo` were checking the result of `refToRelative`,
from `getPkgInfo`'s return object only to call `refToRelative` again.
Calling `refToRelative` directly simplifies code a bit. We can remove an
unnecessary cast and an if statement.
* refactor(dependencies-hierarchy): create enum for getTree nodes
* feature(dependencies-hierarchy): traverse through workspace packages
This updates `pnpm list` and `pnpm why` to traverse through `link:`
packages by simply. This is done by simply implementing a new TreeNodeId
enum variant.
* test(dependencies-hierarchy): test transitive workspace package listing
* refactor(dependencies-hierarchy): create interface for GetPkgInfoOpts
A future commit adds new fields to `getPkgInfo`'s options. The dedicated
interface makes it easier to describe these new options with a JSDoc.
* fix(dependencies-hierarchy): fix path for link: deps in projects
This was a bug before the changes in this pull request. The bug was not
user facing since `pnpm list --json` doesn't print this computed path.
* fix(dependencies-hierarchy): print version paths rel to starting project
* feat(list): add --only-projects flag
* refactor: change description of --only-projects
Co-authored-by: Zoltan Kochan <z@kochan.io>