`pnpm audit` enumerates the install paths to every vulnerable package. The
reachability-based pruning added in 11.5.1 (pnpm/pnpm#12087) lets the walker
skip subtrees that reach no unsaturated finding by precomputing, per node, the
set of vulnerabilities reachable from it.
That getter only memoised acyclic subtrees: a node whose subtree contained a
cycle was `complete === false`, and so was every ancestor up to the importer
roots. None of them were cached, so their reachable set was recomputed on every
query. Real dependency graphs commonly contain cycles, and a single cycle high
in the graph makes a large fraction of nodes non-memoisable, yielding an O(N^2)
walk. This matched the report in pnpm/pnpm#12212 exactly (CPU-bound, identical
audit output across versions).
Reachability is now computed with Tarjan's strongly-connected-components
algorithm. Every node is scanned once; all members of an SCC reach the same set
of vulnerabilities and share one set, finalised in reverse-topological order.
Cyclic graphs are handled in O(N + E).
The reachable set is used only to prune, so it must never under-approximate
(that would hide a real finding). Tarjan yields the exact set for every node,
so no finding can be dropped, and the path-recording logic is unchanged. The
getter returns ReadonlySet<string> so the shared sets cannot be mutated by
callers, and a missing memo entry (an impossible-by-construction state) throws
rather than silently returning an empty set.
A regression test asserts the read-count growth ratio between two cycle sizes
(L=200 and L=400) is sub-quadratic: the fix scales ~2x (linear), the previous
code ~4x (quadratic). Asserting the ratio cancels the per-node constant, so the
test is not brittle to constant-factor changes.
Closespnpm/pnpm#12212.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
* fix(pacquet): read the pnpm CLI manifest from its pnpm11/ location
pnpm/pnpm#12537 moved the TypeScript pnpm CLI under `pnpm11/`, so
`pnpm_version_from` could no longer find `pnpm/package.json`;
`current_source_pnpm_version` then returned `None` and
`package_manager_to_sync` (and its test) failed.
Written by an agent (Claude Code, claude-opus-4-8).
* fix(pacquet): read pnpm's config-reader source from its pnpm11/ location
The `pnpm_default_parity` contract tests read pnpm's `defaultOptions` from
`config/reader/src/index.ts` in the TypeScript tree, which moved under
`pnpm11/` in pnpm/pnpm#12537. Point the path at the new location so the
tests stop failing with a missing-file panic.
Written by an agent (Claude Code, claude-opus-4-8).
Rename the `product: pnpm` label to `product: pnpm@11` and apply it only
when changes touch the pnpm11/ directory. After the TypeScript CLI moved
under pnpm11/, the old "any code outside pacquet/ and pnpr/" instruction
over-matched shared root files (workspace config, CI, docs), so the label
no longer cleanly tracked the TypeScript product.
The TypeScript pnpm CLI freezes at v11; pnpm 12 will be the Rust pacquet
port. To make that split legible, all TypeScript source, test, and build
directories move under a new top-level pnpm11/ directory. The name states
the version boundary rather than implying a behavioral fork, since the two
stacks are meant to behave identically.
Scope is source-only: the shared workspace root stays at the repo root.
pnpm-workspace.yaml, package.json, pnpm-lock.yaml, .pnpmfile.cjs,
.meta-updater, __patches__, .changeset, .husky, and the lint/spell configs
remain in place, so one pnpm workspace and one Cargo workspace still span
all three products. pnpr/client and pacquet/tasks/registry-mock stay as
cross-product workspace members.
Rewiring the move required:
- pnpm-workspace.yaml globs prefixed with pnpm11/
- root package.json script paths, eslint.config.mjs, tsconfig.lint.json,
.gitignore, and CODEOWNERS updated
- .meta-updater/src/index.ts literals repointed (pnpm11/pnpm/package.json,
pnpm11/__utils__, pnpm11/__typings__, and the main package directory)
- regenerated every moved package's repository/homepage URL via meta-updater
- pnpm11/pnpm/bundle-deps.ts and __utils__/scripts/src/typecheck-only.ts
climb one more level to reach the repo root
.meta-updater stays at the repo root because @pnpm/meta-updater resolves
its config at <cwd>/.meta-updater/main.mjs.
TS CI (.github/workflows/ci.yml) now only runs when pnpm11/-relevant paths
change, via a dorny/paths-filter changes job plus a TS CI / Success
aggregate gate; branch protection should require only that gate.
pnpm install --ignore-workspace auto-populated ignored builds into the
allowBuilds map of pnpm-workspace.yaml, overwriting committed true/false
values with the "set this to true or false" placeholder — even under
--frozen-lockfile, which must stay read-only.
The ignore-workspace CLI flag is now a first-class Config field
(ignoreWorkspace, mirroring ignoreWorkspaceCycles) instead of being read
from the untyped cliOptions bag. handleIgnoredBuilds reads it directly and
skips writing to allowBuilds when the workspace is ignored. The strict
ignored-build failure is unchanged.
Closespnpm/pnpm#12469
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
With `auto-install-peers` (default since v8), peer dependencies resolve into
the lockfile indistinguishably from a package's own deps, so `pnpm sbom` lists
them as the package's components. For a published-library SBOM that's wrong —
peers are supplied by the consumer. `--exclude-peers` drops them, plus any
transitive subtree reachable only through them.
peerDependencies come from the manifest (the lockfile carries no marker). The
collector filters those names at each importer's top level, so a peer's
exclusive subtree is never walked while a package also reached via a real dep
stays. With no `--filter`, every importer is walked, so each importer's own
manifest is resolved (via `safeReadProjectManifestOnly`, which tolerates a
missing manifest) and peers in workspace sub-packages are dropped too.
The flag name matches `pnpm list --exclude-peers`; the SBOM behavior is
stricter, pruning the exclusive subtree rather than only hiding leaf peers.
CycloneDX 1.7 has no scope or relationship for "consumer-provided peer", so
omission is the only spec-clean handling.
Known limitation: an aliased peer (`"x": "npm:real@1"` in `peerDependencies`)
is not excluded, since matching is by resolved package name. Aliased peer deps
are vanishingly rare in practice.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
A non-retryable error code (e.g. SELF_SIGNED_CERT_IN_CHAIN) was thrown inside the
detached op.attempt callback, so the governing promise never settled: the caller
hung and the throw surfaced as an unhandled rejection that crashed the process.
Reject the promise instead, mirroring the retries-exhausted path right below it.
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Add @pnpm/resolving.tarball-url, which builds and recognizes the canonical npm
tarball URL of a package. It vendors getNpmTarballUrl (previously the external
get-npm-tarball-url dependency) and adds isCanonicalRegistryTarballUrl.
@pnpm/lockfile.utils (toLockfileResolution, pkgSnapshotToResolution) and
@pnpm/installing.env-installer now import from the new package; the private copy
of the canonical check in toLockfileResolution is removed, and the external
get-npm-tarball-url dependency and its catalog entry are dropped. The vendored
getNpmTarballUrl is byte-for-byte equivalent to get-npm-tarball-url@2.1.0, so the
fetch paths that use it are unchanged.
Two correctness fixes are folded in while consolidating the logic:
- the scoped-package unescape now handles uppercase %2F as well as %2f
(percent-encoding is case-insensitive), so canonical scoped URLs are not
needlessly persisted;
- protocol-insensitive comparison strips only a leading http(s):// scheme via
regex instead of splitting on the first :// (which could truncate a URL
containing a later :// and yield a false-positive "canonical" match).
Both fixes are mirrored in the pacquet port (is_canonical_registry_tarball_url
in pacquet/crates/lockfile/src/resolution.rs) so the two stacks omit the same
canonical scoped registry URLs from the lockfile, with matching regression tests.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
When a project transitions from non-GVS to GVS mode, hoisted symlinks at
`node_modules/.pnpm/node_modules/<dep>` still pointed into
`node_modules/.pnpm/<depPath>/node_modules/<dep>`. The
`symlinkHoistedDependency` function only checked `isSubdir(virtualStoreDir, ...)`
— with GVS active, `virtualStoreDir` is `storeDir/links`, so these old symlinks
were classified as "external" and silently skipped.
Added `internalPnpmDir` (derived from `path.dirname(privateHoistedModulesDir)`)
as a second check. Symlinks under `node_modules/.pnpm/` are now recognized as
pnpm-internal and correctly replaced, matching the pattern in `safeIsInnerLink`.
The broader TypeScript type inference breakage (#9739) also has an architectural
dimension: when TypeScript follows a symlink to storeDir/links/<hash>/, Node's
module resolution walk-up never reaches node_modules/. This is the same root
cause as #12437 (packages from links store can't resolve transitive deps) and
requires a separate architectural fix.
Closespnpm/pnpm#9739
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Add REVIEW_GUIDE.md as the repository's canonical code-review guide, distilled
from the maintainer's review history. Centralize the review framework: move the
AI Review Guidance out of AGENTS.md into the guide, move the engineering
conventions into AGENTS.md's Code Style section, and update the CodeRabbit and
Qodo configs to apply the guide and split review focus between the two bots
(CodeRabbit on correctness/conventions, Qodo on security/performance).
Port yarn berry's popularity-based ident preference (buildPreferenceMap /
getHoistIdentMap) into the real-hoist crate so that, when multiple versions of
one name compete for the root node_modules slot, the most-used version wins —
with the root's direct dependencies always preferred first. This matches pnpm's
hoisted node-linker layout, which pacquet previously diverged from by hoisting
the first-visited version.
Mechanism:
- build_hoist_ident_map / add_dependent / PreferenceEntry build a per-name list
of candidate idents ordered most-preferred-first (root deps, then by the
count of distinct dependents + peer-dependents, stable on ties).
- A new AbsorbDecision::Defer plus is_preferred_ident gates free-slot
absorption: only the currently-preferred ident may take a free root slot; a
non-preferred version stays nested.
- hoist_into_root performs a per-pass ident shift (VecDeque::pop_front) that
promotes the next candidate when the preferred ident still hasn't reached the
root, so a less-preferred version can land once the preferred one is proven
unreachable.
- HoistCtx bundles root, border_names, and the ident map to keep hoist_subtree
within the argument limit.
Tests: unit tests for the preference machinery and most-used-wins precedence, a
dep-graph workspace test, and two frozen-lockfile CLI e2e tests
(single-project and workspace) ported from
installing/deps-restorer/test/index.ts. TEST_PORTING.md is updated accordingly.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
`minimumReleaseAgeExclude` (and `trustPolicyExclude`) ignored every
rule after the first match for a given package, so two separate
exact-version entries like `[form-data@4.0.6, form-data@2.5.6]` could
still trip the policy for the second version while
`[form-data@4.0.6 || 2.5.6]` (a single disjunction entry) worked.
That made list semantics depend on whether the user happened to merge
versions into one `||` selector, which is surprising and unsafe for
supply-chain exclusion lists.
Walk every matching rule and merge consecutive `name@version[...]`
matches in source order with duplicates removed. A bare-name or
wildcard match still terminates the walk, with first-match precedence
between bare and exact rules: a wildcard listed after an
exact-version rule no longer silently widens the exclusion to every
version of the package, while a bare-name rule listed first keeps
its existing `AnyVersion` semantics. Apply the same change to the
pacquet port so both stacks stay in sync.
Closespnpm/pnpm#12463
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Add `pacquet why <pkg>` command that shows the reverse dependency tree
for a given package. Reads the lockfile, inverts the dependency graph,
and renders a tree with cycle detection and deduplication.
Flags: --depth <n>, --exclude-peers (reserved for parity).
* feat(sbom): add issue-tracker external reference from package bugs
Emits a CycloneDX `issue-tracker` external reference for components (and
the root) whose package.json declares a `bugs` URL. The value is parsed
with `new URL()` and kept only when the protocol is http(s), so malformed
values and email-only bug contacts are dropped while uppercase schemes
still pass. A single shared helper handles both transitive components and
the root package. Like homepage and repository, this is populated from
package metadata, so it appears only when the store is read (not in
--lockfile-only mode).
* fix(sbom): emit the normalized issue-tracker URL, not the raw bugs value
bugsUrlFromField used new URL() only as a validity gate but returned the
raw input string, so a crafted http(s) `bugs` value containing CR/LF or
whitespace would pass verbatim into the CycloneDX externalReferences url
(format iri-reference). Return parsed.href instead: new URL strips
CR/LF/tab and percent-encodes spaces and control characters, so the
emitted url is always a clean, single-line value.
* fix(sbom): strip credentials from the issue-tracker URL
bugsUrlFromField returned new URL().href, which preserves any embedded
username:password. An SBOM is a shareable, often-published artifact, so a
bugs value like https://user:token@tracker/... would leak the credentials
into externalReferences[].url. Clear username and password before
emitting; the tracker URL itself stays useful.
On the exact-version disk fast path in pickPackage(), promote the parsed
packument into the in-memory metaCache so later resolutions of the same package
in one install skip the disk read and parse. In large monorepos this brings
adding a package down from minutes to seconds.
The in-memory cache key is now registry-qualified via a shared
getPkgMetaCacheKey(registry, name, fullMetadata) helper. Previously the key was
only the package name (plus a `:full` suffix) while the on-disk mirror was
registry-qualified, so a package of the same name served by two registries in
one install could share one cache slot and resolve the wrong tarball/integrity.
The registry is threaded through createNpmResolutionVerifier's shared-meta reads
(readSharedMeta / readSharedMetaForTrust), which already noted that a
registry-qualified read required the resolver's metaCache key shape to change
first. Added a regression test that resolves the same package name from two
registries and asserts each gets its own tarball.
The Rust pacquet port already implements both behaviors (registry-scoped cache
key and disk-fast-path cache population), so no port is required.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
On Linux reflink_copy::reflink uses FICLONE, which clones only the data into a
freshly-created 0o644 target, dropping the exec bit pacquet encodes in the
`-exec` CAS suffix. pnpm/pnpm#12385 restored it for the copy tier but left clone
out, so an undeclared executable like `@esbuild/linux-x64/bin/esbuild` failed
with EACCES. hardlink shares the store inode's 0o755 and copy was fixed, so only
clone was affected.
Extract the restoration (already duplicated in copy_file and git-fetcher's
materialize_into) into a shared
pacquet_fs::file_mode::restore_exec_bit_from_cas_suffix and route clone through
it. In auto_link / clone_or_copy_link only a reflink failure downgrades; the
post-reflink restoration error stays terminal, so it can't trigger a hardlink
retry against the just-created file that would mask it behind AlreadyExists.
Fixespnpm/pnpm#12500.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* feat(pacquet): honor NODE_EXTRA_CA_CERTS for custom CA trust
pacquet's reqwest client trusts only its bundled webpki roots plus the
.npmrc ca/cafile material — it ignores NODE_EXTRA_CA_CERTS. pnpm running
on Node picks that variable up transitively via Node's TLS runtime, so a
native port has to read it explicitly to keep real-world parity for
users behind a corporate MITM proxy or a self-signed registry.
Load the named PEM bundle as additional trust roots in
default_client_builder (the single chokepoint every client routes
through), keeping the .npmrc-derived TlsConfig env-free. Additive and
lowest-priority; a missing, unreadable, or malformed file is silently
ignored, matching pnpm's silent treatment of a missing cafile. Documented
as the one deliberate exception to the TlsConfig no-env-vars parity note.
* perf(pacquet): load NODE_EXTRA_CA_CERTS once per for_installs
Addresses review on the NODE_EXTRA_CA_CERTS change:
- Read and parse the bundle once in for_installs via
load_node_extra_ca_certs(), then clone the parsed certs into the
default client and each per-registry client. Previously
default_client_builder re-read and re-parsed the file on every call,
i.e. once per per-registry override.
- Use the existing EnvGuard test helper (process-wide lock + restore on
drop, panic-safe) instead of a hand-rolled lock with manual restore.
The test now also asserts a valid bundle parses to one root and a
missing file yields none.
* test(pacquet): align NODE_EXTRA_CA_CERTS test with aube's
Make the pacquet and aube tests mirror each other in form and coverage:
exercise the same four cases in the same order — empty value, valid
bundle (asserting one parsed root plus a successful client build), a
readable non-PEM file, and a missing file — each asserting
load_node_extra_ca_certs() yields the expected roots. Also align the
env-var read in load_node_extra_ca_certs to the same let-else + filter
form aube uses (no behavior change).
* docs(pacquet): fix broken intra-doc links in load_node_extra_ca_certs
The free function's doc used [`Self::for_installs`], but `Self` only
resolves in impl/trait contexts, so rustdoc flagged it as an unresolved
intra-doc link and the Rust CI Doc job (RUSTDOCFLAGS=-D warnings) failed.
Reference [`ThrottledClient::for_installs`] instead.
The 'exec should merge node options with PnP require option' test (added in
#12430) hardcoded an unquoted '--require=<path>' expectation. On Windows the
.pnp.cjs path contains backslashes, which makeNodeRequireOption quotes and
escapes for Node's NODE_OPTIONS tokenizer, so the assertion mismatched and the
test failed on Windows runners.
Derive the expected NODE_OPTIONS from makeNodeRequireOption itself so the
expectation matches the implementation's quoting on every platform.
Approvals on PRs from forks never got the `reviewed: coderabbit` label or a
Discord announcement. A pull_request_review run triggered by a forked PR is
granted a read-only token and no secrets, so the label step failed with HTTP
403 and the Discord step had no webhook.
Split the automation in two: the pull_request_review workflow now only records
the approval as an artifact (no token, no secrets), and a new workflow_run
companion runs from the default branch in base-repo context — where it has the
write-scoped token and secrets — to add the label and post to Discord.
The privileged half never checks out or executes PR content: it reads inert
data files, validates the PR number is an integer, maps the reviewer to a fixed
label, requests only actions:read + issues:write, surfaces a failed (vs absent)
artifact download instead of passing as a silent no-op, and scopes the Discord
webhook secret to the announce step.
Also documents that agents must open PRs using .github/pull_request_template.md,
since gh pr create does not apply it automatically.
Generate a Node.js package map at `node_modules/.package-map.json` on every
isolated or hoisted install, including under the global virtual store, so that
third-party tooling can start experimenting with package maps. The file is
serialized compactly.
Two settings control how the map is consumed:
- `node-experimental-package-map` (default: off): inject
`--experimental-package-map` into `NODE_OPTIONS` for the Node.js scripts pnpm
runs — dependency lifecycle scripts, `pnpm exec`, and `pnpm run` (including
recursive runs).
- `node-package-map-type` (`standard` | `loose`): choose between a strict map
and one that tolerates hoisting-like access.
Covered by both the pnpm CLI and the pacquet (Rust) implementation.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
When a package is reused from the lockfile, its child edges are taken
verbatim and bypass the preferred-versions walk, so a transitive
dependency can stay pinned to an older version even after a direct
dependency resolved to a higher version that satisfies the same range —
leaving the lockfile non-convergent (an incremental install keeps a
duplicate that a fresh install would not).
The resolver now refreshes such a stale pin to the higher
direct-dependency version during resolution, via `preferredVersion`
(singular), which overrides the EXISTING_VERSION_SELECTOR_WEIGHT stability
bias. The older version is never resolved or fetched, and the incremental
result converges to what a fresh install produces. The pick is anchored to
direct dependencies (which resolve first), so it restores the automatic
dedupe removed in pnpm/pnpm#11110 without reintroducing its
non-determinism, and unlike the post-pass in pnpm/pnpm#11502 it does not
over-fetch.
pacquet is ported in the same change. Its full-subtree lockfile reuse is
coarser than pnpm's per-edge reuse, so it records per importer which direct
deps changed and their resolved versions, declines full-subtree reuse for a
parent that depends on a changed direct dep, and forces the higher version
in the child walk. Range satisfaction uses plain semver (not
prerelease-inclusive), matching pnpm's semver.satisfies(.., true).
Add verdaccio's per-uplink tuning knobs to pnpr's proxy uplinks, parsed
from the same config.yaml shape verdaccio uses:
- maxage: per-uplink packument freshness window; overrides the
global packument_ttl when set, otherwise defers to it.
- timeout: per-request deadline applied to every upstream fetch
(default 30s).
- max_fails / circuit breaker: after max_fails consecutive failures the
fail_timeout uplink short-circuits with a 503 until the fail_timeout
cooldown lapses, then one probe per window is allowed
through (defaults 2 / 5m). max_fails: 0 disables it.
- cache: when false, tarballs stream through without being written
to the local mirror (default true).
Interval values accept verdaccio's format ("2m", "30s", "1h30m", or a
bare number of seconds, as either a YAML string or a numeric scalar);
an unparsable or out-of-range value is a named InvalidConfig error
rather than a silent fallback or a panic. Defaults match verdaccio
(timeout 30s, max_fails 2, fail_timeout 5m, cache true).
Circuit breaker behavior:
- Only a 5xx or transport error counts as a failure; a non-404 4xx
(auth / rate-limit / bad request) surfaces verbatim and never opens
the circuit.
- The half-open probe is gated on the cooldown window, so exactly one
probe runs per fail_timeout and a cancelled or dropped probe cannot
stick the breaker open.
- An open breaker reports the configured uplink name, not its upstream
URL, so the 503 does not disclose internal endpoints.
Part of the verdaccio-parity tracking issue pnpm/pnpm#11973.
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
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.