Config dependency names and versions are read from the committed env lockfile
(pnpm-lock.yaml) and the legacy inline-integrity format in pnpm-workspace.yaml,
and both become path segments of the directories pnpm creates during install
(node_modules/.pnpm-config/<name> and the global virtual store's
<name>/<version>/<hash>). They were used unvalidated, so a malicious repository
could commit a traversal-shaped name (../../PWNED) or version (../../../PWNED)
and make `pnpm install` create symlinks or write package files outside those
roots — triggered on install, even with --ignore-scripts.
Add verifyEnvLockfile, an offline structural gate that validates every config
dependency and optional-subdependency name (must be a valid npm package name)
and version (must be an exact semver version) before any path is built from it.
It runs at the install boundary and, through a single writeVerifiedEnvLockfile
seam, before the env lockfile is ever persisted, so an invalid entry is rejected
with no write side effect. __proto__ names are rejected too (the validation
accumulators use null-prototype objects so the key can't slip past Object.keys).
The same fix and structure land in pacquet to keep the two stacks in sync.
Fixes GHSA-qrv3-253h-g69c.
Port the GHSA-j2hc-m6cf-6jm8 fix (pnpm/pnpm#12296) to pacquet.
When pnpm auto-switches to the version requested by `packageManager` /
`devEngines.packageManager`, the bootstrap (`pnpm` / `@pnpm/exe`) must be
resolved through trusted registries only. Pacquet was resolving it through
`config.resolved_registries()`, which a malicious repository controls via
the workspace `.npmrc` or `pnpm-workspace.yaml` `registries:` block.
Add `Config::package_manager_bootstrap`, built in `Config::current()` from a
trusted-only fold of the URL-scoped env, `auth.ini`, and user `.npmrc`
sources (the project `.npmrc` is excluded), reusing the existing
registry/proxy/TLS/auth application logic. It defaults to the public npm
registry, and `PNPM_CONFIG_REGISTRY` still overrides the default because it
is user-controlled.
`EnvInstallerContext::for_package_manager` routes only the package-manager
bootstrap path (`sync_package_manager_dependencies`) through this trusted
config; project `configDependencies` resolution keeps the project
registries, matching the narrow scope of the upstream TypeScript fix.
## What
A repository-wide sweep of the Rust source under `pacquet/` to bring comments in line with the comment policy in `AGENTS.md` / `pacquet/AGENTS.md`: **comments are for the non-obvious _why_, not a translation of the _what_, and behavior already proven by a test should not be re-narrated in code.**
Two focused commits:
**1. Trim comments that restate code or record history** (`refactor(pacquet): trim comments that restate code or record history`)
- comments that merely restate the adjacent code;
- history / refactor-shape narration (`used to`, `before this PR`, `Copilot flagged`, "the old X", removed-type references);
- call-site comments duplicating a callee's own doc comment;
- test prose that just re-describes what a test's name and assertions demonstrate.
**2. Drop comments that narrate test-covered behavior** (`refactor(pacquet): drop comments that narrate test-covered behavior`) — enforcing the "tests are documentation" rule. Each implementation file was paired with its co-located test module so the duplication was visible, then comments enumerating tested scenarios, edge cases, failure modes, or worked examples were removed — **including when they carried a pnpm-parity reference/link**, since that context lives in the test names and git history.
Preserved throughout: genuine `///`/`//!` contracts (guarantees, preconditions, postconditions, panics), hidden invariants, workarounds, ordering/safety reasons, parity notes that aren't behavior-restatement, and `SAFETY:`/`TODO:` notes.
## Scope
Comments only — **no code, identifiers, or string literals were modified** in either commit. ~500 file edits in total; several thousand comment lines removed.
## How it was done
Multi-agent sweeps: one agent per batch applied the policy, and a second agent diff-checked each edited batch for over-deletion (lost contracts) or accidental code changes. Over-trimmed contracts were restored; zero code changes were introduced (independently re-verified: every added line is identical to a removed line modulo a stripped trailing comment).
Auto-approval was enabled (enable_auto_approval + auto_approve_for_no_suggestions)
but never fired because it is evaluated by the /improve tool, which was not in
pr_commands — only /agentic_describe and /agentic_review ran. Add /improve to
pr_commands so the approval check runs, and add auto_approve_for_low_review_effort
= 2 so small PRs are approved even when /improve has minor suggestions.
/improve is added to pr_commands only (not push_commands) to keep Qodo's per-PR
usage low; it runs once when a PR opens rather than on every commit.
CodeRabbit's effective config (from the `@coderabbitai configuration`
command) showed request_changes_workflow: false, which is why it only
ever commented and never approved — CodeRabbit submits approving reviews
only when this is true. The dashboard toggle wasn't taking effect, so
declare it in-repo.
Also explicitly disable pre_merge_checks: they're enabled by default and
in the dashboard (removing the block from the config did not turn them
off), and they add a status block without review value here. Title
enforcement stays covered by the commit-msg hook and squash-title
convention.
* chore: stop configuring CodeRabbit pre-merge checks
Defining any pre-merge check turned on the whole pre-merge-checks
subsystem, which under Request Changes Workflow replaces the
auto-approve flow: CodeRabbit emits a pre-merge-checks status block
instead of approving once review threads are resolved, leaving PRs at
REVIEW_REQUIRED even with all threads cleared and all checks green.
Remove the title check (added in pnpm/pnpm#12460) to restore
auto-approval and document why the block is intentionally absent.
Conventional Commits on the squash title stay covered by the commit-msg
hook and the squash-title convention.
* fix: declare Qodo auto-approval under [config]
enable_auto_approval and auto_approve_for_no_suggestions were declared
under [pr_reviewer], where Qodo never reads them, so auto-approval never
ran. Per the Qodo docs both keys belong under [config]. Move them and
drop the now-empty [pr_reviewer] section.
* chore: stop applying labels from Qodo
Product labels are applied by CodeRabbit (auto_apply_labels +
labeling_instructions in .coderabbit.yaml). Having Qodo also publish
labels via enable_custom_labels/publish_labels/custom_labels meant two
tools managing the same labels. Drop the Qodo label config and leave
labeling to CodeRabbit.
* ci: tag Qodo-approved PRs and fix the label step
Add an on-qodo-approval job that applies the 'reviewed: qodo' label when
qodo-free-for-open-source-projects[bot] approves, mirroring the existing
CodeRabbit job.
The label step used 'gh pr edit --add-label', which is broken (it errors
on the deprecated Projects-v1 GraphQL field), so adding the label failed
on approval. Switch all three jobs to the issues REST API
(POST /repos/{owner}/{repo}/issues/{number}/labels) instead.
## Issue
When `injectWorkspacePackages: true` is set and a workspace package depends on another workspace package that has its own dependencies, running `pnpm rm` from inside the dependent package's directory switches the lockfile protocol from `link:` to `file:`.
Reproduction (workspace where `a` depends on workspace `b`, and `b` has any dependency of its own):
```
cd packages/a
pnpm add redis
pnpm rm redis
# pnpm-lock.yaml: a's "b" entry switched from link:../b to file:packages/b
```
## Root Cause
The fix in #10575 added a defensive guard in `dedupeInjectedDeps` that skipped deduplication whenever the target workspace project's children weren't in `dependenciesByProjectId`:
```ts
if (!targetProjectDeps) {
if (children.length > 0) continue
}
```
In single-project operations (`mutateModulesInSingleProject`, used by `pnpm rm` from inside a package directory) only the operated-on project is resolved. `dependenciesByProjectId` then only has that one project, so the guard fires for any workspace dependency whose target has children, and the protocol stays `file:`.
## Solution
In single-project mode the injected dep is resolved against the same workspace package source, so dedupe is safe — *except* for peer-suffixed depPaths, whose resolution depends on the importer's peer context (a plain `link:` would lose it). The new code dedupes whenever `targetProjectDeps` is missing for a known workspace project and the depPath has no peer suffix. The peer-suffix check compares the depPath against its peer-free `pkgIdWithPatchHash` (depPaths are built as `${pkgIdWithPatchHash}${peerDepGraphHash}`), so it's exact rather than a `(`-substring heuristic.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
## Problem
During warm installs, pnpm relinked existing packages more broadly than necessary when only some child dependencies changed.
In the narrowed relinking path, removed child aliases could also remain behind as stale links after dependency updates.
## Solution
Only pass changed child edges through the relinking path for existing packages.
When a child alias is no longer present in the updated dependency set, remove the obsolete link before relinking. Added regression tests for both cases:
- unchanged child dependencies are not relinked unnecessarily
- deleted child dependencies do not remain as stale links after a warm install
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Fixes#11056
## Problem
On macOS, pnpm imports files from its content-addressable store into `node_modules` via copy, reflink/clone, or hardlink. All three preserve extended attributes, including `com.apple.quarantine`. If a store blob carries that xattr — e.g. it was first written under a Gatekeeper-enabled app such as a Git client (`LSFileQuarantineEnabled=YES`) — the quarantine propagates into `node_modules`. Gatekeeper then blocks ad-hoc-signed native binaries (`.node`, `.dylib`, `.so`) from loading, even though pnpm has already verified each file's integrity against `pnpm-lock.yaml`.
## Solution
After importing a package from the store, strip `com.apple.quarantine` from its native binaries — mirroring Homebrew's behaviour of dropping quarantine from downloads after checksum verification.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* perf(package-manager): drop redundant Arc around process-global compat extender
The built-in `@yarnpkg/extensions` compatibility database is built once
per process and cached in a `OnceLock`. Wrapping it in an `Arc` added an
allocation plus per-install refcount churn for no benefit: a `OnceLock`
already hands out a stable `&'static` reference, and every consumer
(`PackageExtender::apply` / `apply_to_arc`, both `&self`) is satisfied by
`&'static PackageExtender` — which is `Copy + Send + Sync + 'static` and
so moves cleanly into the resolver's `ManifestHook` closure.
Store the extender as `OnceLock<PackageExtender>` returning a
`&'static` reference. The per-install user-provided extender keeps its
`Arc`, since it is constructed fresh each install and shared into the
resolver hook.
* refactor(store-dir): borrow &StoreIndexWriter in upload instead of &Arc
`upload` only reads through the writer — a single `queue_side_effects_upload`
call, no `Arc::clone` — so it never needed shared ownership. Borrowing
`&Arc<StoreIndexWriter>` forced the signature to advertise reference
counting it does not use. Take `&StoreIndexWriter` instead; the sole
caller passes a `&Arc<StoreIndexWriter>` that deref-coerces at the call
site, so it is unchanged.
---------
Co-authored-by: Claude <noreply@anthropic.com>
## Problem
`pnpm version --recursive` did not bump the workspace packages the user selected. In recursive mode the command re-derived the workspace selection itself (via `filterProjectsFromDir`) using an incomplete set of options, so it could resolve a different set of packages than the CLI's actual `--filter`/`--recursive` resolution.
Fixes#11348.
## Change
- In recursive mode, bump the projects in `selectedProjectsGraph` — the selection the pnpm CLI (`main.ts`) already computes from the workspace filter, exactly the way `pnpm publish --recursive` works.
- Remove the in-handler `filterProjectsFromDir` fallback. It duplicated the CLI's filtering logic and was unreachable in production (`main.ts` always passes `selectedProjectsGraph` for a recursive run). `@pnpm/workspace.projects-filter` becomes a dev-only dependency of `@pnpm/releasing.commands`.
- No global/config plumbing is needed for `--recursive`: `@pnpm/cli.parse-cli-args` already recognizes `recursive` (and the `-r` shorthand) for every command, the config reader passes it through to the handler, and the `version` command already declares `recursive` in its own `cliOptionsTypes`.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Mark fixture trees (package.json, lockfiles) as linguist-generated in
.gitattributes so GitHub's dependency graph ignores them. This stops
Dependabot from raising alerts and opening automatic security-update PRs
for packages that appear in fixtures only as test data — e.g. the
js-cookie bump in pnpm/pnpm#11840.
The `@pnpm/test-fixtures` helper package is real source and is
intentionally left unmarked.
* feat(sbom): add monorepo workspace support for SBOM generation
When --filter selects a single workspace, the SBOM root component now
uses that workspace's name, version, description, license, and author
instead of the workspace root's metadata. Author, repository, and
license fall back to the root manifest when the workspace package
doesn't define them.
Workspace inter-dependencies (workspace: protocol) and their transitive
dependencies are now included in the SBOM. Dev-only workspace deps are
correctly excluded when --prod is used, and lockfile-only mode skips
workspace resolution entirely to avoid unexpected disk reads.
* fix(sbom): add recursiveByDefault so --filter works in workspaces
Without recursiveByDefault, the sbom command in a workspace didn't
enter the recursive code path that populates selectedProjectsGraph from
--filter. The handler received no filter info and always used the
workspace root manifest for the root component.
* feat(sbom): add --out and --split for per-package SBOM generation
Add --out <path> to write SBOM to a file instead of stdout. Supports
%s (package name) and %v (version) placeholders. When %s is present,
generates one SBOM per workspace package automatically.
Add --split to output NDJSON (one compact JSON per line) to stdout,
for piping into tooling that processes multiple SBOMs.
Add compact option to CycloneDX and SPDX serializers so NDJSON lines
are single-line JSON without re-parsing.
Sanitize %s and %v values to prevent path traversal in output paths.
* fix: use sbom-out instead of sboms to pass spellcheck
* fix(sbom): expand %v in single-output path and fix workspace dep parent relationships
Two bugs found by CodeRabbit:
1. --out with %v but no %s wrote a literal %v filename. Now both %s
and %v are expanded in the single-output path using the SBOM root
component's name and version.
2. The lockfile walker used rootPurl as parent for every importer,
including additional workspace dep importers. This caused
shared-lib's external deps (like is-odd) to appear as direct
deps of the root package. Now each importer's walk uses the
correct parent PURL based on whether it's an original or
workspace dep importer.
* fix(sbom): fall back to workspace root manifest for filtered package metadata
Read the root manifest from rootProjectManifest/rootProjectManifestDir instead
of opts.dir so license/author/repository/description fall back to the workspace
root when a filtered package omits them. Add the missing rootDescription
fallback. Fix the per-package CycloneDX test assertions to match the standard
group/name split for scoped names.
* fix(sbom): harden path handling and fix lockfile-only/versionless workspace deps
- Neutralize '.', '..' and blank segments in --out path templates so a crafted
package name/version cannot escape the output directory.
- Skip lockfile link: targets that resolve outside the workspace root, and guard
the workspace manifest reads with a containment check, preventing arbitrary
package.json reads from a malicious lockfile.
- Honor --lockfile-only inside collectSbomComponents so workspace links and their
transitive deps are no longer traversed.
- Include workspace packages that omit a version (default to 0.0.0, matching the
root component).
- Bound workspace manifest reads with p-limit to avoid EMFILE on large monorepos.
* fix(sbom): error on per-package output path collisions and use O(n) workspace BFS
- Throw SBOM_OUT_PATH_COLLISION when two workspace packages sanitize to the same
--out path instead of silently overwriting one SBOM with another.
- Replace the resolveWorkspaceDeps BFS queue.shift() (O(n) per dequeue) with a
moving head index for O(n) traversal on large workspaces.
* fix(sbom): strip control chars from output paths and skip unreadable workspace importers
- Strip ASCII control characters (incl. newlines) in sanitizePathSegment so a
crafted package name/version cannot inject lines into the --split --out summary
or produce confusing filenames.
- Skip walking a reachable workspace importer when its package info is missing
(e.g. unreadable manifest) instead of misattributing its deps to the root.
- Bound importer-walker fan-out with p-limit to avoid resource pressure on large
workspaces.
* perf(sbom): reuse workspace manifests across split outputs and use a Set for path collisions
- Read workspace package manifests once into SharedContext (keyed by importer id)
from the project graph, so split mode no longer re-reads them from disk for
every emitted SBOM.
- Track written --out paths in a Set instead of Array#includes to make collision
detection O(1) per package rather than O(n2) overall.
* fix(sbom): support %s in single-project --out, harden importer lookup, cache root license
- Only treat %s in --out as per-package (split) mode when a workspace project
graph is present; in a single-project repo %s/%v interpolate from the root
component on the single-output path. Adds a regression test.
- Use an own-property check for lockfile importer existence so a crafted link:
target cannot follow inherited keys (e.g. toString) via the prototype chain.
- Fast-path resolveRootLicense when the manifest already declares an SPDX license
and cache the workspace-root license in SharedContext, avoiding redundant
on-disk LICENSE probing per emitted SBOM.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
The update command already honors --no-save and the docs already
mention it, but the flag was missing from the update command
metadata.
Add the option entry so pnpm update --help shows it and the CLI
surface matches the documented behavior.
External processes like SSH passphrase prompts can write to the terminal between progress updates. The previous renderer used `ansi-diff`, which only overwrites the characters it knows changed, so leftover characters from the external output stayed visible on the progress line — e.g. `added 0sa':`, where `sa':` is a fragment of `Enter passphrase for key '.../.ssh/id_rsa':`.
Closes https://github.com/pnpm/pnpm/issues/12350
## Summary
The interactive (non-append-only) reporter now redraws the whole frame in place on each update instead of incrementally diffing it:
- return the cursor to the top-left of the previous frame (`ESC[<rows>A` followed by a carriage return, so the redraw starts at column 0 even if an external process left the cursor mid-line),
- erase from there to the end of the display (`ESC[0J`),
- reprint the frame — all in a single atomic write, so there is no flicker.
Because the whole region is erased on every frame, any characters an external process wrote in between are cleared. This matches pacquet's `Output::Frame` rendering (the column-reset hardening was applied to both stacks). The now-unused `ansi-diff` dependency has been removed.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
* fix(deps-installer): re-resolve catalog-referencing overrides on update
When `pnpm.overrides` reference a catalog (e.g. `overrides: { foo: 'catalog:' }`),
`pnpm update` bumped the catalog entry during resolution but left the resolved
`overrides` in the lockfile pointing at the old version. The lockfile's
`catalogs` advanced while `overrides` stayed stale, producing an internally
inconsistent lockfile that fails a later `pnpm install --frozen-lockfile` with
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
After resolution, re-resolve the overrides against the catalog merged with the
update's `updatedCatalogs`, so the lockfile `overrides` track the bumped catalog
just like `catalogs` and direct catalog dependencies do.
* fix(deps-installer): re-resolve catalog overrides before afterAllResolved
Address review feedback:
- Run the catalog-override re-resolution before the `afterAllResolved`
pnpmfile hook instead of after it, so a hook that edits `lockfile.overrides`
still sees and can amend the final value (the block previously ran after the
hook and would clobber its edits whenever a catalog entry was updated).
- Drop the dead `opts.catalogs ?? {}` fallback; `opts.catalogs` is required on
the install options and always defaulted to `{}`, so it is never nullish here.
* test(pacquet): cover catalog-referencing override sync on update --latest
Mirrors pnpm's regression test for keeping lockfile overrides that resolve
through a catalog in sync when `update --latest` bumps that catalog. pacquet
already behaves correctly (it threads the bumped catalogs through to override
parsing), so this is a guard against a future refactor reintroducing the
inconsistency that pnpm/pnpm#12158 fixes on the TypeScript side.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
The pr-review-automation workflow merged in pnpm/pnpm#12462 fails at the
label step with 'Resource not accessible by integration
(addLabelsToLabelable)'. gh pr edit --add-label uses the GraphQL
addLabelsToLabelable mutation, which requires issues: write even when
labeling a PR; pull-requests: write alone is insufficient. Grant it per
job, matching pacquet-integrated-benchmark-comment.yml.
* fix(update): handle mixed direct and transitive selectors
* test(update): strengthen regression test and port to pacquet
The pnpm regression test passed with and without the fix: the fixture's
`latest` dist-tag made a fresh install of `^100.0.0` already resolve to
100.1.0, so the assertion was trivially true. Pin the transitive
dep-of-pkg-with-1-dep to 100.0.0 before install so the test genuinely
fails without the fix and passes with it.
Add pacquet parity regression tests for the same mixed direct/transitive
selector scenario (exact-name and glob forms). pacquet has no equivalent
source change to make — its `update` matches every bare-name/glob
selector against direct deps and locked snapshot names in one pass, so a
direct selector never gates the transitive one — but the behavior is
guarded by tests to lock in pnpm/pnpm#12103 parity.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
## What
- preserve existing `workspace:` dependency specifiers when `updateProjectManifest` saves updated direct dependencies and `preserveWorkspaceProtocol` is enabled
- keep catalog specifiers taking precedence over resolver-normalized specs
- add focused coverage for preserved and normalized local spec behavior
- add a changeset for the published `@pnpm/installing.deps-resolver` change
### pacquet parity
Ported the same fix to pacquet's `update` command. Previously `pacquet update --latest` routed every direct dependency through a registry `latest` lookup, so a `workspace:` local-path dependency (e.g. `workspace:../packages/foo/dist`) was rewritten into a registry version — corrupting the manifest (in the regression test it became `0.0.1-security`). Both `--latest` rewrite sites now skip registry resolution for such specs via `is_workspace_local_path_specifier`, a faithful port of pnpm's `isWorkspaceLocalPathSpecifier`. The gate is unconditional in the `--latest` path because `preserveWorkspaceProtocol` is always on there (its only override derives from `linkWorkspacePackages` under `--workspace`, which cannot be combined with `--latest`).
Fixes#3902
---------
Co-authored-by: morning-verlu <258725120+morning-verlu@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
* ci: label and notify on CodeRabbit approval
Add a workflow that fires on pull_request_review and, when CodeRabbit
submits an approving review, applies the informational "reviewed:
coderabbit" label and (if a DISCORD_WEBHOOK secret is configured) posts
a notice to Discord.
PR fields reach the steps through the environment rather than script
interpolation, so an attacker-controlled PR title cannot inject shell
commands. The Discord step is skipped when the webhook secret is unset.
* ci: flag maintainer-approved PRs for automerge
Rename the approval workflow to cover both review automations and add a
second job: when zkochan submits an approving review, apply the existing
"state: automerge" label.
Like the CodeRabbit job, the PR number is passed through the environment
rather than interpolated into the run script.
* ci: harden PR review automation per review feedback
- Scope permissions to each job (top-level permissions: {}) instead of
granting pull-requests: write workflow-wide (zizmor).
- Set allowed_mentions to {parse: []} on the Discord payload so a PR
title containing `@everyone`/`@here` cannot ping the server.
- Add connect/overall timeouts and retries to the Discord curl call.
Add several CodeRabbit settings:
- path_filters: skip generated/vendored/snapshot files (lockfiles, dist,
fixtures, snapshots, changelogs) so reviews focus on hand-written code.
- pre_merge_checks.title: enforce Conventional Commits on the PR title,
which becomes the commit message under squash merge (the commit-msg
hook only validates per-commit messages).
- knowledge_base.learnings scoped to local so maintainer corrections are
remembered without crossing repo boundaries.
- issue_enrichment labeling: auto-apply the durable area/type taxonomy to
issues, where long-lived labels actually aid triage and search.
- poem disabled.
Secret scanners (gitleaks, semgrep, trufflehog) are already enabled by
default, so they are not added explicitly.
## Summary
Fixes `pnpm install` with `optimisticRepeatInstall` incorrectly returning `Already up to date` when `pnpm-lock.yaml` changed but project manifests did not.
Fixes#12100.
## Root Cause
`checkDepsStatus` used modified manifest mtimes as the only signal for whether it needed to validate dependency status. If no manifest was newer than `workspaceState.lastValidatedTimestamp`, it returned `upToDate: true` before checking whether the wanted lockfile had changed.
That skipped lockfile validation for workflows like:
- `git checkout HEAD~1 -- pnpm-lock.yaml`
- restoring only `pnpm-lock.yaml` from a stash
- external tools rewriting the lockfile without touching manifests
## Changes
- Check wanted lockfile mtimes before taking the optimistic fast path.
- If any wanted lockfile is missing or newer than the workspace state timestamp, validate all projects instead of only modified manifests.
- Add a regression test proving a lockfile-only change does not skip wanted-lockfile validation.
- Add a patch changeset for `@pnpm/deps.status` and `pnpm`.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Mirror the three product labels (product: pnpm, product: pacquet,
product: pnpr) from the Qodo config into CodeRabbit, auto-applied based
on which stack the diff touches. Enables auto_apply_labels so the
labels are attached to the PR rather than only suggested.
Enable Qodo Merge auto-approval when the review finds no suggestions, and
add three custom labels (product: pnpm, product: pacquet, product: pnpr)
assigned by the describe tool based on which stack the diff touches.
Fixes pacquet workspace project enumeration when `pnpm-workspace.yaml` exists but does not define `packages`.
A settings-only workspace manifest like this:
```yaml
allowBuilds:
esbuild: false
```
should still make the project a workspace, but it should only enumerate the root importer. Pacquet was passing the missing packages field through to the lower-level workspace project finder, which uses the recursive `['.', '**']` default. That could accidentally treat vendored fixture packages as workspace projects.
This maps absent packages to `['.']` in the install path, matching pnpm’s config-reader `workspacePackagePatterns` default:
```ts
cliOptions['workspace-packages'] ?? workspaceManifest?.packages ?? ['.']
```
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
## Summary
- `pnpm install` reports "Already up to date" after edits inside a `file:` dependency's directory or after repacking a `file:` tarball. This is a v11 regression from the `optimisticRepeatInstall` default flip in pnpm/pnpm#11158. Fixespnpm/pnpm#11795.
- `checkDepsStatus` gains a `treatLocalFileDepsAsOutdated` option: when set, any project manifest declaring a local file dependency makes the check report not up to date. `installDeps` sets it on the optimistic fast path, so projects with local file dependencies always run a real install, which refetches those dependencies (the v10 behavior).
- The predicate covers `file:` specs, path-prefixed specs (`./`, `../`, `~/`, absolute POSIX paths, and Windows drive paths including drive-relative ones like `C:dir`, matching the local resolver's `isFilespec`), and bare tarball file names (`vendor/pkg.tgz`). It is deliberately narrower than the local resolver's bare-path matching: a bare `user/repo` is statically indistinguishable from a git shorthand at this layer, and matching it would kill the fast path for every project with git dependencies, so protocol-carrying and URL specs stay on the fast path.
- `pnpm.overrides` entries are scanned with the same predicate: an override mapping to a local file spec redirects every matching dependency in the graph to that directory, so it has the same blind spot as a direct local file dependency. Registry and `link:` overrides keep the fast path.
- The option is caller-scoped on purpose. `verifyDepsBeforeRun` also consumes `checkDepsStatus`, and treating `file:` deps as always stale there would force a reinstall before every `pnpm run`. Its behavior is unchanged, and a regression test pins that.
- pacquet port in the same commit: `check_optimistic_repeat_install` bails unconditionally on `file:` specifiers, because its only caller is the install command, the one consumer that sets the flag upstream. `link:` specifiers are excluded on both sides: they are symlinked, so changes inside them flow through without a reinstall.
## Why
Both branches of `checkDepsStatus` are blind to content changes inside a `file:` dependency. The workspace branch exits early with `upToDate: true` when no project manifest's mtime moved, without ever reaching `linkedPackagesAreUpToDate`. The non-workspace branch exits at the manifest-vs-lockfile mtime gate the same way. Editing a source file inside a `file:` dependency bumps neither, so the fast path can never see it; the fix has to bail before those gates rather than refine them. This is the fix shape (a) I proposed in my diagnosis on the issue thread ([comment](https://github.com/pnpm/pnpm/issues/11795#issuecomment-4504177744)): the cost is a full resolution on repeat installs only for projects that declare `file:` dependencies, which is exactly what v10 did.
The manifest-only comparison in `@pnpm/lockfile.verification` (`allProjectsAreUpToDate`) is intentional for the install-proper path and asserted by its tests, so this PR leaves it untouched.
## Checks
- `pnpm --filter @pnpm/deps.status test test/checkDepsStatus.test.ts` (31 passed, 13 new)
- `pnpm --filter @pnpm/deps.status run compile` and `pnpm --filter @pnpm/installing.commands run compile` (tsgo + eslint clean)
- `cargo test -p pacquet-package-manager optimistic_repeat_install` (51 passed, 7 new; run in a rust:1.95.0 container)
- `cargo fmt --check -p pacquet-package-manager`
- `RUSTDOCFLAGS="-D warnings" cargo doc -p pacquet-package-manager --no-deps`
---
Written by an agent (Claude Code, claude-fable-5).
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
## Summary
- use `fs.mkdtemp()` with a short `_tmp_` prefix for CAFS temporary package directories
- remove `path-temp` from `@pnpm/store.create-cafs-store`
- add git-fetcher regression coverage for short CAFS temp directories during git package preparation
- add a patch changeset for `@pnpm/store.create-cafs-store` and `pnpm`
## Why
This leaves more Unix socket path budget for lifecycle tools that create IPC sockets under `TMPDIR` during git-hosted dependency preparation.
Closespnpm/pnpm#12222.
## Pacquet
No pacquet change is included because the Rust git fetcher already uses `tempfile::tempdir()` instead of the TypeScript CAFS `path-temp` suffix changed here.
Ports the virtual-store sweep from pnpm's `prune` to pacquet's isolated linker.
After an install, `node_modules/.pnpm` (pacquet: `.pacquet`) entries the wanted
lockfile no longer references are removed, instead of accumulating across branch
switches and downgrades. Before this, only the hoisted linker removed orphans
(`remove_orphans`); the default isolated linker never pruned its virtual store.
- **Sweep** — `prune_virtual_store`: needed set = `node_modules` + one
`depPathToFilename` per non-skipped snapshot key; readdir the virtual store;
rimraf the rest. Mirrors `prune.ts` L180-190.
- **Throttle** — `should_prune_virtual_store` mirrors the `pruneVirtualStore` gate
(`index.ts` L471-473) and `cacheExpired` (L1180-1182): skip under the global
virtual store; otherwise prune unless `prunedAt` is within `modulesCacheMaxAge`.
An unparseable `prunedAt` is treated as not-expired, matching pnpm's
`NaN > maxAge == false`.
- **`prunedAt` lifecycle** — now stamped only when a sweep ran or on first install,
else the prior value is preserved (`index.ts` L1828). Pacquet previously stamped
it every install, which would have wedged the throttle off.
## Notable deviation
The sweep keeps `lock.yaml` (the current lockfile), whereas upstream deletes it and
unconditionally rewrites it. Pacquet's current-lockfile write is *conditional*
(skipped when `config.lockfile` is off), so deleting it in the sweep could orphan
it. Preserving it is end-state-equivalent whenever the rewrite runs and strictly
safer when it doesn't. Making pacquet's current-lockfile write unconditional (full
parity) is a separate, pre-existing concern.
## Scope
Only the virtual-store sweep slice of `prune()`. Deferred (other roadmap items /
follow-ups): changed-direct-dep removal, the subset-importer orphan calculation,
and the `pnpm:stats` `removed` count + `pnpm:removal` debug channel.
## Tests
- Unit: sweep (keeps needed + `lock.yaml`; removes surplus dir and skipped
snapshot; missing-dir no-op) and throttle (both directions; empty/unparseable/
future timestamps; global-store exclusion).
- Integration: a first install with a pre-seeded surplus `.pacquet` dir asserts the
dir is swept while the installed package's slot survives. Confirmed it fails when
the wiring is disabled.
Refs #11633 (Rust roadmap, Stage 1 Tier 4).
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Components reachable only through devDependencies now get
`scope: "excluded"` plus the `cdx:npm:package:development` property in
CycloneDX output. The `excluded` scope documents non-runtime/test usage
(valid in every exported spec version, 1.5/1.6/1.7); the property is the
CycloneDX npm-taxonomy marker emitted by `@cyclonedx/cyclonedx-npm`, so
both modern and existing consumers are covered. Runtime-reachable
components (ProdOnly, DevAndProd, and installed optionalDependencies)
omit both and default to `required`.
Run peer dependency range checks with semver loose parsing while preserving
prerelease inclusion.
This prevents pnpm peers check from reporting react-native-reanimated@4.4.0 as
unmet for ranges like >=3.16.0 || >=4.0.0-.
Add a lockfile regression fixture for #12149 and a patch changeset for the peer
checker and pnpm CLI.
Closes#12149.
Co-authored-by: OpenAI Codex <codex@openai.com>
* fix: warnings in `store|config` should be printed to `stderr`
* refactor: extract stderr-reporter command list into a named set
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* fix: invalid gvs workspace
* test: cover GVS workspace virtual-store-dir + add changeset
Add an e2e regression test for the post-install build step preserving the
global virtual store directory in a workspace package's .modules.yaml, and
the missing changeset for the fix (pnpm/pnpm#12307).
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
## Description
Adds a `--dry-run` option to `pnpm install` with **npm-style preview semantics**: it runs a full dependency resolution and reports what a real install **would** add/remove/update, but writes **nothing** to disk (no lockfile, no `node_modules`, no `.modules.yaml`, no workspace-state file) and **always exits 0**.
```
$ pnpm install --dry-run
Dry run complete. A real install would make the following changes (nothing was written to disk):
Importers
.
+ is-negative 1.0.0
Packages
+ is-negative@1.0.0
```
When the lockfile is already up to date it prints `Dry run complete. pnpm-lock.yaml is up to date; a real install would make no changes.`
Resolves https://github.com/pnpm/pnpm/issues/7340.
### Why this shape
An earlier attempt (#12270, now closed) implemented `--dry-run` as `--frozen-lockfile --lockfile-only` — i.e. a fail-on-drift *lockfile validator*. That collides with the well-established meaning of `--dry-run` across npm/yarn ("preview, never fail") and duplicated existing behaviour (`pnpm install --frozen-lockfile --lockfile-only` already does that). This PR implements the intuitive preview meaning instead.
### How it works (pnpm)
- Reuses the existing `lockfileCheck` callback (resolve fully, skip the lockfile write, hand back the before/after wanted lockfile) plus `lockfileOnly` (skip `node_modules`, the workspace-state file, and metadata-cache writes).
- The frozen/headless fast path is disabled whenever `lockfileCheck` is set, so a check-only install always resolves and never materialises anything.
- The before/after lockfiles are diffed (reusing the dedupe diff engine, now exported as `calcDedupeCheckIssues`) and rendered into the report.
- `--dry-run` with a configured pnpr server is rejected (that path resolves/links through the server).
### Pacquet
Ported in the second commit — `pacquet install --dry-run` forces the fresh-resolve path, skips every write (a new `dry_run` flag on `InstallWithFreshLockfile` skips the `pnpm-lock.yaml` save), and a new `dry_run` module diffs the existing lockfile against the freshly-resolved one and prints the same report.
* feat(pacquet): add --ignore-scripts to install
`pacquet install` runs the dependency build phase on the fresh-lockfile
path (since pnpm/pnpm#12436), so a project with blocked build scripts
(e.g. sharp, esbuild) fails with `ERR_PNPM_IGNORED_BUILDS` under the
default `strictDepBuilds`. pnpm's answer is `--ignore-scripts`; pacquet
had no equivalent, so there was no way to install such a project without
approving every build.
Add `--ignore-scripts`, mirroring pnpm: a `Config.ignore_scripts` field
fed by the CLI flag (merged enable-only at the Install dispatch, like
`--frozen-store`). When set, the during-install build loop bypasses its
allow-build gate entirely — no script runs and nothing is recorded as an
ignored build, so the install no longer fails under `strictDepBuilds`.
Patches still apply, and the project's own lifecycle scripts are skipped
too, matching pnpm's `ignoreScripts`.
This also unblocks the vlt.sh benchmark, whose pacquet command was the
only one missing the `--ignore-scripts` the pnpm command already passes,
making pacquet show up as DNF on every fixture.
* fix(pacquet): honor ignore_scripts for git deps and from workspace yaml/env
Two follow-ups from PR review:
- Git and git-hosted-tarball dependencies were fetched with
`ignore_scripts: false` hardcoded, so their `prepare` script still ran
during install even under `--ignore-scripts`. Thread
`config.ignore_scripts` into `GitFetcher` / `GitHostedTarballFetcher`,
and flip the git store-index key's `built` dimension to
`!ignore_scripts` at both the write site (`install_package_by_snapshot`)
and the warm-prefetch site (`snapshot_cache_key`) so the two stay in
lock-step and address the same slot.
- `Config.ignore_scripts` was only set by the CLI flag; `ignoreScripts`
in `pnpm-workspace.yaml` and `PNPM_CONFIG_IGNORE_SCRIPTS` were silently
dropped. Wire it through `WorkspaceSettings` + `apply_to` and the env
overlay, mirroring `strictDepBuilds`.
Branch protection required the individual Rust CI and TS CI jobs as
status checks. That is brittle: most Rust jobs skip on PRs that touch no
Rust files, and a matrix job skipped at the job level never expands its
'${{ matrix.* }}' name — GitHub reports one check under the literal
unexpanded name, so the required per-OS contexts never appear and any
non-Rust PR blocks forever waiting for status that is never reported.
Add one static-named aggregate gate per workflow ('Rust CI / Success'
and 'TS CI / Success') that runs with 'if: always()' and fails only when
a dependency actually failed or was cancelled; skipped dependencies
count as a pass. Branch protection can then require just these two
contexts, decoupling them from the matrix shapes.
TS CI runs on both push and pull_request, and on same-repo PRs the
pull_request run skips every job for deduplication. A naive gate would
report a green 'TS CI / Success' on that skipped run and let a PR merge
before the real push run finished. The gate's name is therefore
'TS CI / Success' only on runs that do real work, and a different name
on the skipped duplicate run — using 'compile-and-lint''s 'if:' verbatim,
which the name MUST stay in sync with. Rust CI needs no such guard: its
push trigger is restricted to main, so a PR produces a single run.
Requires a branch-protection update: replace the per-job Rust CI / TS CI
contexts with 'Rust CI / Success' and 'TS CI / Success'.
When `package.json` sets both `packageManager` and `devEngines.packageManager` to the same pnpm version with the same integrity hash pnpm prints a spurious warning on every command. For example, a `package.json` file that looks like:
```json
{
"packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485",
"onFail": "ignore"
},
"runtime": [
{
"name": "node",
"version": "26.3.0",
"onFail": "ignore"
}
]
}
}
```
Issues a warning on every `pnpm` command:
> Cannot use both "packageManager" and "devEngines.packageManager" in package.json. "packageManager" will be ignored.
## Root cause
`getWantedPackageManager` compares the two fields to decide whether to warn, but the two sides were normalized differently:
- `parsePackageManager` **strips the integrity hash** from the legacy `packageManager` field → `11.5.1`
- the `devEngines.packageManager` version was compared **with its hash intact** → `11.5.1+sha512.93f7b57…`
So, `"11.5.1" !== "11.5.1+sha512…"` was always true and the warning fired, even for identical specs. An earlier fix in #11307 only suppressed the warning when *neither* side carried a hash.
## Fix
`parsePackageManager` now also returns the hash (via a shared `splitPackageManagerVersion`), and `getPackageManagerConflictWarning` compares the fields structurally. The warning is suppressed **only when the two specifiers are identical** (name + version + hash, both-absent counts as equal):
| name | version | hash | result |
|------|---------|------|--------|
| same | same | both absent, or both present & equal | ✅ no warning |
| same | same | present on **one side only** | ⚠️ generic "Cannot use both…" |
| same | same | both present & **differ** | ⚠️ "…contradictory integrity hashes" |
| same | **differ** | — | ⚠️ "…different versions of pnpm" |
| **differ** | — | — | ⚠️ "…different package managers" |
A hash on only one side is still a divergence — dropping the ignored `packageManager` field would lose that hash — so it warns with the original generic message. Two contradictory hashes for one version (a likely wrong-hash mistake) get a dedicated message. The generic single message is otherwise replaced by one tailored to each conflict, each ending with `"packageManager" will be ignored`.
Closes#12028.
---------
Signed-off-by: C. Spencer Beggs <spencer@beggs.codes>
Signed-off-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Zoltan Kochan <z@kochan.io>
## Summary
Fixes lockfile churn where a package's `transitivePeerDependencies` (e.g. `supports-color` via `debug`) could be dropped — and shift between packages — when the package participates in a dependency cycle. Which packages carry a given transitive peer depended on resolution order, so upgrading an unrelated dependency churned the lockfile.
## Root cause
When peer resolution walks into a cycle, the cycle is broken by dropping the repeated package's subtree, so the re-entry occurrence resolves against truncated children and looks peer-free. That occurrence was then recorded as "pure" in `purePkgs` — a verdict keyed by package id, not by context. A later occurrence of the same package, reached through a different parent that *can* see the full subtree, hit the `purePkgs` short-circuit and returned an empty peer set, dropping the transitive peers it should have surfaced. Because the outcome depends on which occurrence is walked first, it was order-dependent.
## Fix
Don't record a cycle re-entry's resolution in `purePkgs` / `peersCache` (a re-entry is detected when the package id already appears in the ancestor chain). Its truncated peer sets aren't authoritative for the package as a whole, so leaving the caches untouched lets later occurrences resolve correctly — or reuse the package's authoritative, non-truncated entry via `findHit`. This is a minimal guard at the cache-population site: it adds no post-resolution pass and does not change `transitivePeerDependencies` for packages that aren't in cycles.
This PR also includes an independent fix: when collecting peer providers from a node's children, match each child's resolved package name in addition to its alias, so `pnpm add my-alias@npm:real-pkg` is visible to peer resolution when `real-pkg` is a peer dependency name.
Both the TypeScript pnpm CLI and the Rust (pacquet) port are updated in parity.
Fixespnpm/pnpm#5108
Related `transitivePeerDependencies`-instability reports: pnpm/pnpm#5552, pnpm/pnpm#5794, and the `transitivePeerDependencies` aspect of pnpm/pnpm#9992 (the out-of-scope version drift in pnpm/pnpm#9992 is a separate problem and is not addressed here).
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Closes [pnpm/pnpm#12203](https://github.com/pnpm/pnpm/issues/12203).
On Windows, `node` is linked by hardlinking `node.exe` directly rather than through a cmd-shim. The early-exit in `linkBin()` only recognized an existing cmd-shim, so on every warm install the existing (already correct) `node.exe` was treated as a conflict: pnpm warned `The target bin directory already contains an exe called node` and then removed and re-linked it. Because many commands re-link `node`, the warning was spammed.
### `@pnpm/bins.linker`
Before warning and replacing an existing `.exe`, check whether it already refers to the link target via a new `isSameFile()` helper:
- Compares the OS file identity (inode/device), read as `BigInt` to avoid the precision loss NTFS 64-bit file IDs suffer when cast to a `Number`. A zero/unreliable inode (common on Windows) is not treated as a match.
- Falls back to a streaming, chunked (64 KB) content comparison when identity can't be established, so a byte-identical copy still counts as the same file without ever buffering a whole executable. A read error during the comparison is treated as "not the same file" so it can never abort bin linking.
The early-return is scoped to the `node.exe` path, so non-`node` commands still fall through to the existing warn + remove + `cmdShim()` regeneration and never end up with a partially populated bins directory.
### pacquet
pacquet never emitted this warning, but its Windows `link_node_bin` unconditionally removed and re-linked `node.exe` on every install. Ported the same same-file early-return to `cmd-shim` so warm installs skip that churn, using `same_file::Handle` for the cheap identity check (promoted from a transitive to a workspace dependency) with the same chunked content fallback.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
When running a non-recursive `pnpm run --no-bail` that matches multiple scripts (e.g. via a `/regex/` selector), pnpm always exited with code `0` regardless of whether any script failed. This is inconsistent with recursive runs, which aggregate failures and exit non-zero at the end (via `throwOnCommandFail`).
This PR fixes `--no-bail` directly so its exit-code behavior is consistent across recursive and non-recursive runs, as requested in https://github.com/pnpm/pnpm/issues/8013:
- `--no-bail` still runs every matched script to completion (it no longer short-circuits on the first failure — execution switched from `Promise.all` to `Promise.allSettled`).
- After all scripts settle, the command exits with a non-zero exit code (`ERR_PNPM_RUN_FAILED`) if any of them failed.
This is a behavior change: previously a non-recursive `pnpm run --no-bail` with a failing script exited `0`. No new flag is introduced — per the issue discussion, a separate flag "would just add confusion without benefit".
Closes https://github.com/pnpm/pnpm/issues/8013
* fix(install): persist revoked builds to .modules.yaml (#12221)
After a build-script dependency whose approval was revoked (e.g. via
git stash dropping allowBuilds from pnpm-workspace.yaml) is re-added,
the revocation detection populated ignoredBuilds in memory but the
install path's writeModulesManifest had already run, so .modules.yaml
never recorded the revoked packages. pnpm approve-builds then read an
empty ignoredBuilds and reported 'no packages awaiting approval'.
Re-read the manifest from disk after the revocation scan and write
back the updated ignoredBuilds, merging with any entries the install
path captured.
* refactor(install): address review comments
- Inline dead ignoredBuildsFromInstall indirection
- Drop unsafe allowBuilds cast in the regression test
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>