Skip to content

feat: tighten minimumReleaseAge — auto-exclude, lockfile verification, and interactive prompt#11705

Merged
zkochan merged 36 commits into
mainfrom
improve-loose-min-age
May 18, 2026
Merged

feat: tighten minimumReleaseAge — auto-exclude, lockfile verification, and interactive prompt#11705
zkochan merged 36 commits into
mainfrom
improve-loose-min-age

Conversation

@zkochan

@zkochan zkochan commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

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 toocreateNpmResolutionVerifier 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 ResolveResultPackageResponse.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

Test plan

  • Unit tests for the inline violation flow (installing/deps-installer/test/install/minimumReleaseAge.ts): fresh immature pick surfaces resolutionPolicyViolations, lockfile verifier rejects unlisted entries in loose mode, loose-mode entries already on the exclude list pass, exclude list short-circuits the violation report, handleResolutionPolicyViolations decline aborts before lockfile write, approval lets install proceed.
  • Unit tests for the policy handlers (installing/commands/test/policyHandlers.ts): TTY detection, strict-no-TTY plan throws with the full violation list, loose-mode noop on the hook, strict + --no-save refusal, manifest-patch shape, dedup.
  • Unit tests for makeResolutionStrict (installing/client): violation → NO_MATURE_MATCHING_VERSION mapping, no-throw when the resolver returns no violation.
  • Unit tests for the npm resolver: inline violation reporting on fresh resolve, peek path, and the named-registry / jsr shared shell.
  • Unit tests for createNpmResolutionVerifier: trustedPublisher → provenance downgrade detection, same-evidence-level pass, abbreviated meta shortcut requires the pinned version to exist, exclude-list canTrustPastCheck rejects shrinkage. Trust-fetch errors surface the underlying message in the violation reason.
  • Unit tests for verifyLockfileResolutions: single-code batch keeps the per-policy code, mixed-code batch escalates to LOCKFILE_RESOLUTION_VERIFICATION with per-entry codes in the breakdown.
  • E2E tests (pnpm/test/install/minimumReleaseAge.ts, pnpm/test/dlx.ts): verifier rejects unlisted entries in loose mode, fresh-resolution auto-add (incl. transitives), dlx error message format on strict immature pick, round-trip clean install once exclude list is populated, recursive + non-recursive --no-save leaves the workspace manifest untouched.
  • CI runs the full suite.
  • Manual smoke test of the interactive prompt (recommended before merge).

Written by an agent (Claude Code, claude-opus-4-7).

Summary by CodeRabbit

Release Notes

  • New Features

    • Added interactive prompts for strict mode to approve immature package versions before installation.
    • Implemented automatic exclusion of immature packages in workspace configuration.
    • Added trust policy verification to detect package downgrades during lockfile verification.
  • Improvements

    • Enhanced error reporting for policy violations in non-interactive environments with complete violation lists.
    • Improved lockfile verifier with strict policy checks and caching optimizations.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 17, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements end-to-end collection, confirmation, and enforcement of immature package picks through the pnpm install pipeline. Resolution policy violations (minimum release age, trust downgrade) are detected inline during npm package selection, collected during dependency tree resolution, surfaced to an optional handler hook for approval or rejection, persisted to workspace manifest auto-excludes in loose mode, and verified against the lockfile on subsequent installs when strict mode or frozen-lockfile is enabled.

Changes

Immature picks + verifier

Layer / File(s) Summary
Policy violation types and constants
resolving/resolver-base/src/index.ts, resolving/npm-resolver/src/violationCodes.ts, store/controller-types/src/index.ts
ResolutionPolicyViolation interface captures policy check outcomes (name, version, resolution, code, reason), re-exported through store and install layers; violation code constants centralized for reuse.
Resolver policy violation detection
resolving/npm-resolver/src/index.ts, installing/package-requester/src/packageRequester.ts
Simplifies NoMatchingVersionError to generic form, adds detectMinReleaseAgeViolation helper to construct violation objects when publish timestamp exceeds cutoff, augments resolve results with policyViolation fields, and threads violations through package requester to store.
Verifier extension with policy checks
resolving/npm-resolver/src/createNpmResolutionVerifier.ts, resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts
Refactors verifier activation to check both minimumReleaseAge and trustPolicy='no-downgrade', introduces separate runAgeCheck/runTrustCheck with independent caches, tightens canTrustPastCheck to validate normalized policy fields including trust config, fail-closes abbreviated metadata shortcut when pinned version missing.
Hook threading through dependency resolution
installing/deps-resolver/src/index.ts, installing/deps-resolver/src/resolveDependencies.ts, installing/deps-resolver/src/resolveDependencyTree.ts, installing/deps-installer/src/install/extendInstallOptions.ts, installing/deps-installer/src/install/index.ts
Adds handleResolutionPolicyViolations hook option awaited between dependency-tree resolution and peer resolution, collects violations in ResolutionContext, surfaces them through ResolveDependenciesResult to install mutation layer.
Policy handler framework
installing/commands/src/policyHandlers.ts, installing/commands/test/policyHandlers.ts
Introduces setupPolicyHandlers composing registered handlers (minimum release age), sequences hook execution to avoid prompt races, merges workspace-manifest patches; minimum release age handler implements strict/loose modes with TTY prompting, --no-save validation, and immature-entry collection.
Install/mutation integration of handlers
installing/commands/src/installDeps.ts, installing/commands/src/recursive.ts, installing/deps-installer/src/install/index.ts
Threads policy handlers into installDeps and recursive, wires hook into resolve options, captures violations from mutations, derives workspace patches via pickManifestUpdates, gates all persistence on opts.save !== false.
Lockfile verification refactor with violation collection
installing/deps-installer/src/install/verifyLockfileResolutions.ts, installing/deps-installer/test/install/verifyLockfileResolutions.ts
Refactors verifyLockfileResolutions to delegate to internal iterateLockfileViolations, adds cache-free collectResolutionPolicyViolations export, deduplicates by name+version+resolution, applies concurrency limiting, formats errors with sorted breakdown and mixed-code detection.
CLI tools integration: strict resolution wrapper
installing/client/src/index.ts, installing/client/package.json, installing/client/tsconfig.json, exec/commands/src/dlx.ts, engine/pm/commands/src/self-updater/selfUpdate.ts
Introduces makeResolutionStrict wrapper converting resolver policyViolation to thrown PnpmError, remaps MINIMUM_RELEASE_AGE_VIOLATION_CODE to NO_MATURE_MATCHING_VERSION, wires into dlx/self-update to conditionally wrap resolver when strict policies enabled.
Error handling and reporter updates
cli/default-reporter/src/reportError.ts, deps/inspection/outdated/src/createManifestGetter.ts, deps/inspection/outdated/test/getManifest.spec.ts, exec/commands/test/dlx.e2e.ts, installing/commands/test/add.ts
Updates error reporters to handle optional packageMeta and removes immatureVersion guidance, adjusts createManifestGetter to check policyViolation.code instead of throwing, updates test assertion patterns.
Resolver and verifier option threading
resolving/default-resolver/src/index.ts, resolving/npm-resolver/src/pickPackage.ts, store/connection-manager/src/createNewStoreController.ts
Drops strictPublishedByCheck from resolver/picker options, extends verifier factory options with trust-policy fields (trustPolicy, trustPolicyExclude, trustPolicyIgnoreAfter), threads through default-resolver and store, removes fast-path error rethrow logic.
Tests and integration coverage
installing/deps-installer/test/install/minimumReleaseAge.ts, resolving/npm-resolver/test/publishedBy.test.ts, resolving/npm-resolver/test/resolveJsr.test.ts, pnpm/test/install/minimumReleaseAge.ts, pnpm/test/install/misc.ts
Adds Jest suites for policy handlers (modes, TTY, save validation), verifier trust gating/downgrade prevention, lockfile verification mixed codes, strict-mode hooks, and integration tests for auto-exclude behavior and trust-downgrade rejection.
Fixtures, config, and documentation
__fixtures__/pnpm-workspace.yaml, installing/commands/src/install.ts, installing/commands/package.json, installing/commands/tsconfig.json, .changeset/auto-collect-minimum-release-age-exclude.md
Sets fixture workspace config to minimumReleaseAge: 0 to disable verifier, adds CLI --no-save option schema, documents strict/loose behavior in changeset, updates package dependencies.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • pnpm/pnpm#11622: Both PRs modify resolving/npm-resolver/src/pickPackage.ts to refactor minimumReleaseAge resolution behavior by dropping strictPublishedByCheck and adjusting maturity/fallback handling.
  • pnpm/pnpm#11691: Main PR's lockfile verification refactor in verifyLockfileResolutions directly builds on retrieved PR's shift toward verifier arrays and cached verification.
  • pnpm/pnpm#11704: Both PRs modify npm-resolver minimumReleaseAge time-lookup logic in createNpmResolutionVerifier to use layered/attestation-based fetching.

Poem

🐰 Violations collected, prompts presented with flair,
Immature picks confirmed before they're placed with care,
Auto-excludes written when users give the nod,
Strict mode trusts the verifier—a most trustworthy prod!
From resolution tree to lockfile's final say,
Policy-checked packages light the builder's way. ✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch improve-loose-min-age

@zkochan zkochan changed the title feat: auto-collect immature versions into minimumReleaseAgeExclude when strict mode is off feat: tighten loose minimumReleaseAge — auto-exclude + lockfile verification May 17, 2026
@zkochan zkochan changed the title feat: tighten loose minimumReleaseAge — auto-exclude + lockfile verification feat: tighten minimumReleaseAge — auto-exclude, lockfile verification, and interactive prompt May 17, 2026
@zkochan zkochan added the area: supply chain security Issues related to minimumReleaseAge, blockExoticSubdeps, build script safety, and trust policies. label May 17, 2026
@zkochan zkochan marked this pull request as ready for review May 17, 2026 14:41
Copilot AI review requested due to automatic review settings May 17, 2026 14:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

zkochan added 11 commits May 17, 2026 17:06
When `minimumReleaseAgeStrict` is off, loose-mode picks (the
lowest-version fallback and the peekManifestFromStore fast path that
reuses lockfile-pinned versions) are now captured and persisted to
`minimumReleaseAgeExclude` in pnpm-workspace.yaml. A single info
message lists what was added so users see the bypass becoming
explicit on disk instead of staying silent.
The verifier no longer skips when `minimumReleaseAgeStrict` is false.
Now that loose-mode auto-collect persists every accepted-immature
pin to `minimumReleaseAgeExclude`, the verifier can require every
lockfile entry to be either mature or on the exclude list — same
contract strict mode enforces. Lockfiles produced under a weaker
or absent policy that still hold immature entries are rejected the
same way they would be in strict mode.

Also tightened the resolver-side `onImmaturePick` callbacks so an
entry whose specific version is already on the exclude list isn't
re-announced as immature on every install.
testDefaults<T>'s generic inference loses the InstallOptions
intersection when T contains a callback field that's not in its
explicit allowlist. Spread the testDefaults result and add
onImmaturePick afterward — typecheck sees the full
InstallOptions shape and the test exercises the same path.
In strict mode + TTY, instead of throwing
ERR_PNPM_NO_MATURE_MATCHING_VERSION on the first immature pick (which
forces the discover-by-loop dance described in #10488), the resolver
now collects every immature direct and transitive in one pass and
pnpm prompts the user with the full list before peer-dep resolution
runs.

Approve → install proceeds, the new entries are persisted to
minimumReleaseAgeExclude at the end (same path loose mode uses).
Decline → resolution aborts before the lockfile, package.json, or
modules dir are touched. Strict mode without a TTY (CI) keeps
today's fail-fast behavior.

Implementation:
- New \`deferImmatureDecision\` flag threaded through the resolver
  chain. When set, pickRespectingMinReleaseAge falls back to the
  lowest version (instead of throwing in strict mode) so every
  immature pick fires onImmaturePick.
- New \`confirmImmaturePicks\` checkpoint in resolveDependencies,
  invoked between resolveDependencyTree and resolvePeers. Throws to
  abort.
- setupImmaturePicks in installing/commands wires the prompt: TTY
  detection, enquirer confirm with default=No,
  ERR_PNPM_MINIMUM_RELEASE_AGE_DENIED on decline.
`(pkg) => { picks.push(pkg) }` triggered @stylistic/brace-style
(single-line block body) and `(pkg) => picks.push(pkg)` infers
`=> number` which collides with the void return type elsewhere
in the type intersection. `(pkg) => void picks.push(pkg)` avoids
both — the `void` operator pins the expression to void without a
block.
The prepareFixtures step runs \`pnpm install -rf --frozen-lockfile\`
across every fixture. Some fixtures (e.g. workspace-external-depends-deep,
has-outdated-deps) have lockfiles pinned to \`@pnpm.e2e/*\` packages from
the local registry-mock, which aren't on npmjs.org. The v11 default
\`minimumReleaseAge: 1440\` spins up the lockfile verifier — and with
verification now running in loose mode, the verifier fail-closes on
the un-checkable entries. The actual minimumReleaseAge code paths
have their own dedicated unit and e2e tests, so disabling the policy
on the fixture scaffolding keeps the build green without losing
coverage.
Four review fixes:

1. --no-save preserves the workspace manifest. drainImmaturePicks no
   longer logs and clears the collector when opts.save === false; the
   next install resurfaces the picks as expected.
2. pickFromSimpleRegistry forwards onImmaturePick and
   deferImmatureDecision. JSR + named-registry resolutions now feed the
   same auto-collect / prompt path as plain npm.
3. setupImmaturePicks uses ci-info's isCI in addition to the TTY check
   (overridable per-call), so CI runners that allocate a TTY still get
   strict-mode fail-fast behavior.
4. Verifier policy snapshots minimumReleaseAgeExclude and
   canTrustPastCheck rejects when today's list isn't a superset of the
   cached one. Removing an exclude entry forces re-verification so the
   now-uncovered immature pin is flagged.

Plus: targeted tests for each (--no-save e2e, JSR onImmaturePick unit,
ci=true + TTY unit, cache-invalidation e2e), and the pacquet-parity note
in the changeset.
The previous superset-based canTrustPastCheck was correct for the
hole the review described (removing an entry forces re-verify), but
the layered reasoning — "trust the cache when today's list is a
superset of the cached one" — invites future bugs and was unnecessary
for the security-sensitive cache contract. Switch to a byte-for-byte
JSON.stringify match on a sorted+deduped snapshot.

Cache identity now equals policy identity: any change to
\`minimumReleaseAgeExclude\` invalidates the cached run, including
additions. The extra re-verification on additions is rare (users
mostly add entries after a failure, not as a routine), and removes
the class of bypasses where a previously-approved version stays
trusted after its exclude entry has been pulled.
The CI-detection check in setupImmaturePicks imports \`ci-info\`
from src/, so it needs to be a runtime dependency rather than a
devDependency. Lint flagged this with import-x/no-extraneous-dependencies.
The verifier already re-checks minimumReleaseAge on every lockfile
entry to catch policy violations that bypassed fresh resolution
(peek path, --frozen-lockfile, restored CI cache). The trust check
had the same blind spot: failIfTrustDowngraded was only running at
resolver time, so a downgraded entry that was committed before the
policy was enabled (or that resolved through the peek path on a
later install) would never trip the gate.

createNpmResolutionVerifier now activates when either
minimumReleaseAge OR trustPolicy='no-downgrade' is set, fetches the
full packument once per package, and runs both checks against the
same metadata. Failures map to MINIMUM_RELEASE_AGE_VIOLATION or
TRUST_DOWNGRADE codes respectively. trustPolicyExclude /
trustPolicyIgnoreAfter pass through with the same semantics they
have at resolution time.

The cache policy snapshot records the trust fields too — any change
to trustPolicy / trustPolicyExclude / trustPolicyIgnoreAfter
invalidates the cached run (consistent with the exact-match rule
already used for minimumReleaseAgeExclude).

Plumbing: ClientOptions / ResolutionVerifierFactoryOptions /
CreateNewStoreControllerOptions all forward the three trust fields
to the verifier factory. Two e2e tests cover the lockfile-bypass
case and the exclude-list short-circuit.
Copilot AI review requested due to automatic review settings May 17, 2026 15:08
@zkochan zkochan force-pushed the improve-loose-min-age branch from f7cb61c to f59fab0 Compare May 17, 2026 15:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@karlhorky

karlhorky commented May 17, 2026

Copy link
Copy Markdown
Member

@zkochan thanks for this, looks great for local users!

What about non-interactive mode also discovering all immature transitive dependencies for Renovate and similar? (non-interactive environments)

Otherwise, with strict projects Renovate will still be stuck in the loop of pnpm install + parse version from ERR_PNPM_NO_MATURE_MATCHING_VERSION error + configure minimumReleaseAgeExclude:

@zkochan

zkochan commented May 17, 2026

Copy link
Copy Markdown
Member Author

This PR also updates the non-strict install (with minimumReleaseAgeStrict=false) to automatically add all exclusions to pnpm-workspace.yaml. So they could use that.

Four review issues addressed:

1. Recursive --no-save no longer drops the auto-excludes silently —
   the drain runs inside the opts.save !== false guard like
   installDeps does, and the unconditional fallback write at the
   recursive workspace-level path is gone.

2. The trust verifier no longer fast-paths on "any attestation."
   That shortcut would have let through a downgrade from
   trustedPublisher (rank 2) to provenance (rank 1) — a case
   resolver-time failIfTrustDowngraded correctly rejects (see
   trustChecks.test.ts:261). The check now always fetches the full
   packument and runs failIfTrustDowngraded — same fidelity the
   resolver enforces. The per-version attestation cache is removed
   along with the unsafe path.

3. The abbreviated-modified shortcut for the maturity gate now
   requires the pinned version to be present in the abbreviated
   metadata's `versions` map. Without that check an unpublished or
   never-published pin could pass on a stale package-level
   `modified` timestamp.

4. pnpm-lock.yaml refreshed for the ci-info dep move
   (devDependencies → dependencies of installing/commands).

Plus tests:
- New `resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts`
  with focused unit coverage: trustedPublisher → provenance
  downgrade is rejected, same-evidence provenance passes,
  abbreviated shortcut bails on missing version, canTrustPastCheck
  invalidates on exclude-list shrinkage.
- Recursive --no-save e2e test mirroring the single-project one.
@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

zkochan added 2 commits May 18, 2026 02:55
CodeRabbit-found gap + safety net for the policy-handler contract:

* The lockfile verifier (`createNpmResolutionVerifier`) didn't accept
  `ignoreMissingTimeField`, so on registries that strip per-version
  `time` the verifier failed closed while the resolver picker (via
  `pickMatchingVersionFinal`) warn-and-skipped. Same install could
  resolve successfully and then fail verification. The verifier now
  takes the flag, warns once via the shared `warnMissingTimeFieldOnce`,
  and passes the entry when set. Threaded through
  `ResolutionVerifierFactoryOptions` and `ClientOptions` so
  `minimumReleaseAgeIgnoreMissingTime` flows from the user config
  (via createNewStoreController) all the way to the verifier.

* `resolveDependencies` in deps-resolver now throws
  ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED if violations fire
  but no `handleResolutionPolicyViolations` callback was wired. The
  policy contract is "every pick that trips a check produces a
  violation that gets handled"; a missing handler silently let
  policy-rejected versions land in the lockfile. Tests that
  inspected `result.resolutionPolicyViolations` without a handler
  now wire an explicit no-op (the contract is "acknowledge or
  abort", not "ignore").

Tests added:
  - createNpmResolutionVerifier passes the entry under
    ignoreMissingTimeField when no source surfaces a publish
    timestamp.
  - resolveDependencies throws when violations fire and no handler
    is wired.
Copilot AI review requested due to automatic review settings May 18, 2026 01:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

zkochan added 2 commits May 18, 2026 08:07
optimisticRepeatInstall short-circuits via checkDepsStatus, which
compares the current config to the WorkspaceStateSettings snapshot
persisted in node_modules/.pnpm-workspace-state-v1.json. The list of
tracked keys didn't include minimumReleaseAge / trustPolicy / their
exclude lists / ignore-after / ignoreMissingTime — so turning a
policy on, shrinking an exclude list, etc. left the workspace state
"clean", the optimistic short-circuit fired, and the verifier
fan-out never got a chance to re-check the lockfile against the new
policy.

Add the policy keys to WORKSPACE_STATE_SETTING_KEYS so
checkDepsStatus correctly reports "not up to date" when any of them
changes; the verifier's own per-lockfile cache keeps the cheap-
repeat case cheap once the install actually runs.

Revert the diagnostic in pnpm/test/install/misc.ts now that the
"Already up to date" output has been surfaced and the underlying
issue is fixed.
The lockfile-resolution verifier writes its per-content-hash result
cache to <cacheDir>/lockfile-verified.jsonl. `pnpm store prune` is
documented as "prune unreferenced packages and clean the cache" but
only ran the store's own prune + the dlx-cache cleaner — the
verifier cache file was left behind, which `test/store/storePrune.ts`
caught by asserting cacheDir is empty after prune.

Add a `cleanLockfileVerifiedCache(cacheDir)` helper alongside
`cleanExpiredDlxCache`. Silent on a missing file so prune stays
idempotent. The cache file name is duplicated by hand on both sides
(here and in verifyLockfileResolutionsCache.ts); a shared package
would be heavier than tracking one stable string in two places.
Copilot AI review requested due to automatic review settings May 18, 2026 06:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 45 out of 46 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

@zkochan zkochan merged commit 4195766 into main May 18, 2026
20 checks passed
@zkochan zkochan deleted the improve-loose-min-age branch May 18, 2026 07:51
KSXGitHub pushed a commit that referenced this pull request May 18, 2026
… in new test fixtures

origin/main advanced with four PRs touching files this branch also changes:

- #11679 (fix: silence verify-deps auto-install output) — TS only, no conflict with pacquet/.
- #11705 (feat: tighten minimumReleaseAge) — TS only, no conflict.
- #11708 (refactor(pacquet): optional DI pattern + documentation) —
  renames the test-DI seam (`Api` generic → `Sys`, `RealApi` → `Host`,
  `FsReadString` → `FsReadToString`, fakes drop `*Api` suffix) across
  modules-yaml / cmd-shim / config / reporter / package-manager / cli,
  and adds a "Dependency injection for tests" section to
  `pacquet/CODE_STYLE_GUIDE.md` plus a corresponding rule 7 in
  `pacquet/AGENTS.md`. Most files auto-merged cleanly with this branch's
  single-letter renames; only `pacquet/crates/patching/Cargo.toml`
  needed a manual resolve where this branch added `[lints] workspace = true`
  and main added `text-block-macros` to dev-dependencies — combined.
- #11710 (test(pacquet): coverage) — adds 44 unit + integration tests.
  Two of the new closures (`|v|` in `registry/package/tests.rs:84` and
  `|e|` in `lockfile/save_lockfile/tests.rs:337`) tripped
  `perfectionist::single_letter_closure_param` because they predate this
  branch's enablement of the rule; renamed to `|version|` / `|entry|` to
  keep dylint at zero warnings post-merge.

Verified: `cargo check --workspace --tests --locked` clean,
`cargo dylint --all -- --all-targets --workspace` clean,
`cargo fmt --check` clean.
zkochan added a commit that referenced this pull request May 18, 2026
* feat: report lockfile verification progress

The lockfile resolution verifier introduced in #11705 runs an unbounded
registry round-trip on cache miss and was previously silent — on a cold
registry cache users saw nothing for several seconds. Emit pnpm:lockfile-verification
log events (started/done) around the actual verification pass and render
them in the default reporter as a transient progress line that collapses
into a final "verified" summary with entry count and elapsed time. The
cached short-circuit stays silent.

* feat: include lockfile path in verification log and render when non-standard

Add `lockfilePath` to the `pnpm:lockfile-verification` event payload so
consumers always know which lockfile a `started`/`done` pair refers to.
In the default reporter, render the path in the message only when the
lockfile lives outside the workspace root (or, for non-workspace
installs, outside cwd) — the common case stays uncluttered, while
custom `lockfileDir` setups now surface in the verification line.

* feat: name what the lockfile verification actually checks in the rendered message

"Verifying lockfile" was opaque about *what* was being verified. Reword
the rendered messages to explicitly name the check ("supply-chain
policies"), so users on a cold-cache pause understand what's happening
instead of just seeing the pause.

* fix: skip lockfile verification emission for empty candidate set

A non-empty lockfile.packages whose snapshots all fail name/version
extraction would still emit a "Verifying lockfile (0 entries)" line even
though no verifier work runs. Bail before emission when the candidate
map is empty so the no-op branch stays silent, matching the contract
for the other no-op branches (empty verifiers, no lockfile.packages).

* fix(reporter): always close out the verifying-lockfile frame

Address two Copilot review points on #11712:

1. The verifier emitted `started` but no terminal event when violations
   were found or when the registry fan-out threw, leaving "Verifying
   lockfile…" as the last frame for that block in ansi-diff mode (and
   an unmatched line in CI logs). Add a `failed` status to the logger,
   wrap the fan-out in try/finally so a terminal event is emitted on
   every exit path that emitted `started`, and render a brief failure
   line so the spinner-style frame is replaced before the PnpmError
   block prints.

2. The path-suppression heuristic used strict `===` between
   path.dirname(lockfilePath) and expectedDir, which broke on trailing
   separators and slash-direction differences. Switch to a
   path.relative-based check so a workspaceDir like `/repo/` or a
   Windows path with mixed slashes still correctly suppresses the
   redundant "at <path>" suffix.

* docs: update lockfile verification logging behavior

The lockfile verifier now emits log events during the registry round-trip pass, improving user visibility into the process.
@karlhorky

karlhorky commented May 18, 2026

Copy link
Copy Markdown
Member

@karlhorky in comment 4472432710: IIRC I've seen pnpm sometimes do unusual things with upgrading transitive dependencies which are unrelated to what I'm currently trying to upgrade...

@zkochan in comment 4472470709: Well, I guess so, but you'll have the whole list of such packages. They won't be upgraded unnoticeably. You'll be able to reject if something gets updated that you don't want to.

@zkochan with "you", I guess you mean the end user? and I guess "the whole list of such packages" are the Git additions in the PR to minimumReleaseAgeExclude?

so an end user would need to review each minimumReleaseAgeExclude in the PR to carefully ensure that those are transitives of the upgraded package? any command / tool you would suggest for that? eg. pnpm list piped to jq:

$ pnpm list '@next/env' --depth Infinity --lockfile-only --json
[
  {
    "name": "security-vulnerability-examples-next-js-postgres",
    "dependencies": {
      "next": {
        "version": "16.2.6",
        "dependencies": {
          "@next/env": {
            "version": "16.2.6"
          }
        }
      }
    }
  }
]

# Passing example - `@next/env` is a transitive dep of `next`
# -> it should be allowed to be added to minimumReleaseAgeExclude
$ pnpm list '@next/env' --depth Infinity --lockfile-only --json \
  | jq -e '
      def contains_package($name):
        (.dependencies // {}) as $deps
        | ($deps | has($name)) or any($deps[]; contains_package($name));

      any(.[].dependencies["next"]?; contains_package("@next/env"))
    '
true

# Failing example - `css-tree` is NOT a transitive dep of `next`
# -> it NOT should be allowed to be added to minimumReleaseAgeExclude
$ pnpm list css-tree --depth Infinity --lockfile-only --json \
  | jq -e '
      def contains_package($name):
        (.dependencies // {}) as $deps
        | ($deps | has($name)) or any($deps[]; contains_package($name));

      any(.[].dependencies["next"]?; contains_package("css-tree"))
    '
false

this assumes a hypothetical security PR similar to upleveled/security-vulnerability-examples-next-js-postgres#395 (but with the full minimumReleaseAgeExclude additions)

and instead of the end user, I guess way better would be if Renovate could run that command / tool instead, as part of the PR creation... 🤔

@zkochan

zkochan commented May 18, 2026

Copy link
Copy Markdown
Member Author

"the whole list of such packages" are the Git additions in the PR to minimumReleaseAgeExclude?

yes

zkochan pushed a commit to timhaines/pnpm that referenced this pull request May 19, 2026
Adds a regression test verifying that `pnpm outdated` honors
`minimumReleaseAge` by not surfacing immature newer versions as available
upgrades. The underlying fix already shipped as part of pnpm#11705 (which
removed `strictPublishedByCheck` entirely and routed maturity decisions
through `policyViolation`); this lands the dedicated test from the
original repro PR (pnpm#11699).
zkochan pushed a commit that referenced this pull request May 19, 2026
Adds a regression test for #11698. The underlying fix already shipped as part of #11705 (which removed `strictPublishedByCheck` entirely and routed maturity decisions through `policyViolation`), so this PR now lands only the dedicated test that locks in the behavior.

## What the test covers

`deps/inspection/commands/test/outdated/minimumReleaseAge.test.ts`:

- **Baseline** — without an age policy, `pnpm outdated` reports `is-negative@2.1.0` as an available upgrade (sanity check that the fixture actually has outdated deps).
- **Regression** — with `minimumReleaseAge` set to a cutoff so far in the past that every published version is immature, `pnpm outdated` reports nothing: `exitCode === 0` and `2.1.0` does not appear. Before #11705 this test went red because the non-strict resolver fallback re-picked the immature `latest` ignoring `publishedBy`.

The `allImmatureMinimumReleaseAge = Date.now() / (60 * 1000)` trick (cutoff = epoch in minutes) is date-independent and matches the technique already used in the install-side `minimumReleaseAge` suite.

## Why a test-only PR

The original PR proposed flipping `strictPublishedByCheck` in `createManifestGetter`, but #11705 deleted that option entirely and replaced it with an always-defer model (`policyViolation` flows through `ResolveResult` → `getManifest` returns `null` on `MINIMUM_RELEASE_AGE_VIOLATION`). The test was the durable contribution; preserving it as a regression gate is worth keeping.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: cli/dlx area: lockfile area: supply chain security Issues related to minimumReleaseAge, blockExoticSubdeps, build script safety, and trust policies.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

minimumReleaseAgeExclude: withTransitives option

3 participants