A forced install re-materializes every slot, so the fast path must not
short-circuit it on an unchanged workspace. Nothing named that gate: it
was caught only incidentally, by a global-virtual-store test that would
have failed on stale bytes rather than on the reason for them.
Assert the observable signal instead — a forced install does not report
"Already up to date". Verified by removing the gate, which fails the new
test on its own assertion.
`install.rs` rather than `repeat_install.rs` so the case also runs on
Windows; the latter is `#![cfg(unix)]`.
The one-line note on `Config::force` replaces a sentence that described
the frozen path's skip discard as though it were the whole rule.
Related to pnpm/pnpm#15094
Resolution can return the same immature package version in multiple dependency contexts. Filter those records by name and version in the minimumReleaseAge handler, keeping distinct versions and the existing sorting and approval behavior.
Dialoguer clears only the current line before rendering the selected answer. Give it only the approval question and write the preceding version list once while the reporter is paused. Preserve prompt errors, default denial, and exclude persistence.
Cover duplicated records in approval and non-interactive tests in both CLI versions. Both approval regressions retain multiple versions of one package. A Unix pseudo-terminal CLI regression checks that answering the real prompt prints the version list once and persists the approved exclusions.
Closespnpm/pnpm#15083.
A catalog is declared in `pnpm-workspace.yaml`, so a relative path in an
entry has to be measured from that directory rather than from the project
that dereferences it. Catalog resolution now re-anchors a `file:` / `link:`
entry on the consuming project's directory.
That is the job the root-level `pnpm.overrides` rewrite already did for its
own local targets, so its `LocalTarget` moves into the new
`pnpm-local-spec` crate and both read from it. The shared version anchors
through `pnpm_fs::relative_path`, which lexically normalizes both sides and
refuses to diff across Windows path prefixes.
`resolve_from_catalog` takes the anchor as a required argument, so every
call site states whether it installs from the entry or only compares,
displays, or re-anchors it itself. The install resolver re-anchors on the
declaring manifest's directory; `pnpm dlx` renders an absolute path because
it installs outside the workspace; overrides, peer ranges, `catalogMode`,
`pnpm outdated`, and the published manifest read the entry as written.
The lockfile's `catalogs:` snapshot keeps the entry exactly as
`pnpm-workspace.yaml` writes it, and now records an entry with no version
of its own at all, so editing a local entry's path invalidates the
lockfile. Its `version` holds the resolved path for such an entry, taken
from the lowest-sorting importer that resolves it.
A `~/` path is not absolute as far as `Path::is_absolute` is concerned, so
re-anchoring joined it onto the declaring file's directory and produced a
path through a literal `~` component. The local resolver expands `~/`
against the home directory itself and records the specifier verbatim, so
such a path names the same place from every directory and is left where it
is written, like an absolute one. `pnpm.overrides` shared the flaw and is
fixed with it.
`pnpm pack` and `pnpm publish` re-anchor a local entry on the package
being exported. A `file:` dependency written directly in a project's
manifest is exported unchanged and resolves from that project, so a
catalog entry carrying the same text has to mean the same place rather
than a path from the workspace root.
Closespnpm/pnpm#8642
Naming no version asks for whatever the workspace agreed on, so a new
dependency the catalog already lists takes the entry rather than the range
`latest` happens to produce today. pnpm 11 decides this the same way, in
`parseWantedDependencies`, which is why only pnpm 12 reported a mismatch.
A range reaching the catalog check still has to equal the entry. Answering
`catalog:` swaps the wanted range for the entry's, so a range that merely
falls inside it would lose what the dependency asked for, and pnpm widens the
entry for nobody.
The concurrency and reporting halves of one add test now sit in separate
tests. A bare add of a cataloged package no longer requests `latest`, so the
two could not share a fixture.
Closespnpm/pnpm#14865
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Keep the previous install's lockfile entries available under --force in
pnpm v12. Snapshot reuse and slot replacement already gate on the force
flag, while child-link cleanup needs the previous dependency records.
In v11, pass the current lockfile to headless child-link cleanup regardless
of incremental relinking, and compute obsolete aliases for packages selected
for re-import by the resolving installer. Reuse the existing dependency-diff
and removal helpers, preserving full relinking during forced installs.
Add matching regressions for fresh and frozen installs in both versions,
including ordinary and scoped optional child links. Assert the links are
absent even when their targets have been pruned, and that the package and
its remaining dependency are still installed.
Closespnpm/pnpm#15039.
Nine files under `crates/cli/tests/suite/` carried a file-level
`#![cfg(unix)]`, and the two `pnpm`-differential tests in `root.rs` and
`bin.rs` carried a Windows `ignore`, so none of them ever compiled or ran
on the Windows CI job.
Four of the gates blamed "program not found" on spawning the external
`pnpm`. That dates from pacquet's own CI, which installed pnpm through
npm as a `pnpm.cmd` shim: Windows resolves a bare program name against
`.exe` alone, so the spawn failed. The workflows have installed pnpm
through `pnpm/setup` since 2026-05, and that action downloads pnpm's
native `pnpm.exe`, which resolves. Nothing in those four files is
unix-specific beyond the spawn.
The other gates were harness details rather than platform behavior:
- `repeat_install.rs` compared inode numbers to tell a file the install
reused from one it replaced. It now takes a hard link as a witness and
compares with `same_file`, which answers the same question on both
platforms.
- `multiple_importers.rs` asserted the executable bit on a linked bin.
`bundled_dependencies.rs` already had a helper that checks the bit on
Unix and the `.cmd` / `.ps1` launchers on Windows; it moves to the
shared test utils and both files call it.
- `pnpx_alias.rs` copied the binary under a name with no `.exe` suffix.
- `ignore_workspace.rs` ran `pwd`, and `pipeline_watch.rs` ran `mkdir -p`
and `cp`, from a package script. Both now use `node -e`, as
`pipeline_cache.rs` already did.
- `pipeline_cargo_cache.rs` ran its built probe binary by a path with no
`.exe` suffix.
Five files stay gated, each with a comment saying why: `interrupt.rs`
sends POSIX signals, `run_recursive.rs` and `exec_recursive.rs` put POSIX
shell bodies in nearly every package script, and
`global_virtual_store.rs` and `hoisted_node_linker.rs` assert layouts
shaped around symlinks. Un-gating those is the remainder of
pnpm/pnpm#15089.
Related to pnpm/pnpm#15089.
`pnpm cache list-registries` printed the mirror directory name where `pnpm
cache view` printed a decoded registry URL, so the two disagreed about what a
registry is called and the listing gave no way to tell a live entry from a
stale one. Both stacks already exported the decoder beside the encoder; only
the listing never called it. Fixed in v11 and v12, since the inconsistency is
in both.
Keying the mirror on the scheme and path stranded every directory written
before it: the registry now resolves to a different name, and `list`, `view`
and `delete` all scope their glob to the configured registry's current key, so
nothing reads the leftover and nothing could remove it. `pnpm cache prune`
removes the directories this version can no longer read, across all three
metadata roots. New command, so v12 only.
`is_unreadable_registry_key` decides what goes. A name carrying no scheme
separator predates the current shape, with one exception it must not confuse
for staleness: `get_registry_name` collapses a key over 255 bytes to a bare
sha256, which carries no separator either. Those are spared, so one
unreachable directory can survive rather than a live mirror being deleted.
`--dry-run` lists what would go without removing it. The cache directory is
shared machine-wide and the scheme-in-key change shipped in v11.27.0 as well as
v12.4.0, so a pnpm at 11.26 or earlier still reads the host-only names this
removes; a user on both versions can check before paying that CLI a refetch.
The ephemeral-port directories the issue also mentions are in the current key
shape, so reclaiming them needs a retention policy rather than a mechanism,
and are left out. The descriptor-scoped roots under `v11/metadata-private`
accumulate equally unreadable directories and are also skipped, as `delete`,
`list` and `view` skip them.
Also stops `cacheList` and `cacheDelete` from asserting an exact cache
listing while the update check resolves `pnpm@latest` through the same cache
dir. That check is off under CI, so the stray `pnpm.jsonl` entry only failed
these tests locally.
Closespnpm/pnpm#15046.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
When minimumReleaseAge requires fetching full package metadata to inspect
per-version publish times, omit etag and modified conditional headers.
Abbreviated document validators cannot validate a full packument
representation. Omitting them prevents registries that reuse ETags across
representations from returning 304 Not Modified without time data.
Closespnpm/pnpm#14925
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Leave a dependency supplied by packageExtensions, a readPackage hook, or an
override where the hook put it: skip the manifest upsert so it is not copied
into package.json, and keep the specifier the hook gives it.
Resolve within that specifier. An update, including an audit fix, still moves
the dependency inside the hook's range; only --latest is held back from
resolving past it. Warn when a hook pins an exact vulnerable version, since
only a change to the hook or an override can fix that. Keep hook-owned
dependencies out of catalog promotion, which would resolve them through a
range nothing records.
Classify explicit additions against the original manifest so users can still
declare hook-provided and aliasless dependencies.
Related to pnpm/pnpm#14928
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot-Session: b8173664-abb8-4b7d-88cd-5a1b08e03c3e
The completion server previously offered only candidates from clap's command
and option definitions, leaving the run script position empty.
Initialize clap metadata before scanning boolean options and register the
run-script alias. Track positional arguments and directory options in the
completion context.
Reuse the npm project prefix lookup and manifest reader to return sorted script
names. Share workspace-root lookup with command execution, preserve explicit
directories, and respect equals-only options. Escape zsh candidate separators
after prefix filtering. Read manifests only at the run script position, before configuration or
hooks are initialized, and propagate malformed manifest errors.
The missing-script regression affects v12 only. The TypeScript v11 implementation
already reads scripts from the project manifest. A related Bash glob expansion
bug affects both versions and is fixed in both generated scripts. Preserve
candidate lines without splitting or pathname expansion, quote Bash candidates
for insertion, escape PowerShell
candidate syntax, and normalize its quoted prefixes. Reuse the short-option
scanner so combined directory and workspace-root flags select the same project
as command execution.
Closespnpm/pnpm#15034.
pnpm/pnpm#15041 gave `generate_sh_shim` a `relocatable_root` parameter
and `is_shim_pointing_at` a `shim_path` one. pnpm/pnpm#14963 branched
before that and merged after it, adding four call sites that still pass
the old argument lists. Each was green on its own base, and their merge
does not compile, so `cargo test` fails on every branch cut from main.
The call sites are all `#[cfg(unix)]`, which is why the Windows Clippy
job stayed green while the Linux and macOS test jobs failed.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A task the recursive runner dispatches holds every script a RegExp selector
matched in that package, and it ran them in sequence. `RunArgs::run` has started
them together since it gained `run_selected_scripts`, and pnpm 11 fans them out
through its `limitRun` limiter, so the recursive Rust path was the odd one out.
`run_project` now picks between a concurrent and a sequential mode the way the
single-project path does, sharing the verdict bookkeeping through
`ScriptRunState` so both record the same transitions.
Running several scripts per task breaks the equivalence the scheduler relied on:
capping dispatched tasks caps running scripts only while a task runs one script
at a time. `ScriptBudget` restores it. It is one counting semaphore for the whole
run, sized by `script_concurrency` over every matched script, and each script
holds a permit while it runs. A permit is never held while waiting for another,
so the budget cannot deadlock, and single-script tasks are unaffected: the outer
task cap and the budget are then the same number and no script ever waits.
Two consequences of the concurrency needed handling:
- A task's own failure outranks the cancellation it causes. Under `--bail` the
failing script cancels the run, its sibling is killed, and the sibling's
cancellation used to overwrite the task's verdict, because `RunOutcome::record`
reads `cancelled` before `failed`. The run then reported
ERR_PNPM_RECURSIVE_FAIL instead of naming the project with
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL, unlike a failure in another package.
`ScriptRunState::cancelled_script` keeps the failure.
- A script must not start on a permit it won after the run was cancelled.
`run_stages` returns early on `SlotOutcome::Ungated` without consulting the
tracker, so such a script reached `run_script` and spawned a process that
registration then killed. `start_script` returns no permit in that case, which
is the guard `runRecursive.ts` applies after its own limiter on pnpm 11.
Closespnpm/pnpm#14933
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generated POSIX bin shims resolve cygpath and wslpath through the caller's PATH,
which allows a dependency with a bin named cygpath or wslpath to override path
conversion on Cygwin, MSYS2, and WSL2.
The POSIX bin shim header now tries each helper via command -p first. If
execution succeeds with non-empty output, the system helper's result is used.
If command -p fails or returns empty output, it falls back to searching the
caller's PATH as before, so a host without the helper on the system default
path keeps working.
Both warm-install staleness checks -- is_sh_shim_hardened in pacquet and
isShimHardened in @pnpm/bins.linker -- now require the two conversion lines as
well. Without that, a shim written after the readlink hardening but before this
one still points at the right target, so nothing else about it looks stale and
an upgrade would never replace it.
Fixespnpm/pnpm#14866.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pnpm update` folds `devEngines.runtime` and `engines.runtime` entries into
`devDependencies` and `dependencies` as `runtime:<range>` specifiers. The pass
that moves a declared range onto the version the run resolved recognized only
the `npm:` and `jsr:` prefixes, so a `runtime:` declaration kept its old text in
`package.json` while the lockfile recorded the new version.
Both update paths now dispatch on the dependency's alias. A `node` declaration
is saved through the node resolver's own
`normalize_node_runtime_version_specifier`, the rule `add` and `--latest` use: a
stable pick keeps the declared operator, a prerelease is pinned exactly so the
release channel it came from survives, an unknown channel is left for the
resolver to reject, and a range selector keeps the `runtime:` protocol.
`deno` and `bun` declare through `runtime:` too, but their resolvers report the
declared selector back unchanged, so an update leaves their declarations as
written and records an explicit selector as asked, under the protocol a bare
rewrite used to drop.
Closespnpm/pnpm#14988
---------
Co-authored-by: Tung Lam <lamphamabtung96@gmail.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--force` reaches the link phase with the previous install's `packages:`
records withheld, and `package_content_changed` reads a missing current
entry as "unchanged", so `SlotImportSource.force` came out false for
every slot. `import_indexed_dir` then short-circuited on the
`package.json` completion marker and left the earlier install's files in
place: a package whose integrity changed kept its old contents while the
lockfile recorded the new one, at exit 0 with no warning.
Carry the reuse policy's `force` into that decision instead of inferring
it from records `--force` itself withholds. Grouping the two `packages:`
maps and the flag as `SlotReuse` gives the rule one home for the warm
and cold batches and keeps `ColdBatch` within the field cap.
Rule early materialization out under `--force` too. The link phase now
replaces every slot, so slots populated during resolution would only be
staged away again, which would have imported each package twice on the
platforms where that optimization runs.
Carry `.pnpm-needs-build` under a forced import as well. Replacing a
slot's files restores the pristine base map, since the side-effects
overlay is applied later by the build phase. Under the global virtual
store that phase would otherwise read the re-imported files as a cache
hit and skip the rebuild, leaving a built package reverted. The marker is
what distinguishes a pristine re-import from a finished build, which is
why an interrupted build already carries one.
pnpm 11 is not affected. Its `forceImportPackage` is
`!depIntegrityIsUnchanged`, and `isIntegrityEqual(resolution, undefined)`
is false, so a withheld record makes it force the import rather than skip
it. The Rust port drew the opposite conclusion from the same input.
Closespnpm/pnpm#15030
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Project bin shims embedded absolute NODE_PATH entries and target markers,
and .bin/node used an absolute symlink. Moving or copying the project
could leave binaries resolving dependencies from the original location.
Add relocatable_root to LinkBinsOptions. On Unix, write in-root
NODE_PATH entries and target markers relative to the shim directory,
and make in-root node runtime links relative. Apply the same option
when injected dependencies are relinked.
Resolve the shim directory physically with cd -P and pwd -P for both
absolute and relative invocations. Node normalizes parent segments
lexically, so an unresolved directory symlink can send NODE_PATH
outside the project. This adds a subshell for project shims using a
physical directory anchor. Escape the relative segments for
double-quoted sh strings and stop if the directory cannot be resolved.
Upgrade existing absolute in-root node links before the warm-install
shortcut. Preserve already-relative links without recreating them.
Keep Windows rendering and out-of-root shim behavior unchanged.
Resolve bin directories, target parents and extra NODE_PATH entries before
computing relative paths, including symlinked and not-yet-created directories.
Broader moved-node_modules support remains outside this PR.
Related to pnpm/pnpm#6937.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Avoid replacing global package groups when a lockfile-only update resolves to the same graph and the group still carries the modules manifest an install leaves behind. Preserve update depth, downgrade protection, pending build approvals, policy reporting, and one command-level completion summary.
Fixespnpm/pnpm#12002.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
`pnpm deploy`, `pnpm rebuild`, `pnpm rb`, and `pnpm setup` now run a `package.json` script of the same name, as documented. `pnpm pm <name>` still forces the built-in command.
The resolver that `clean` / `purge` already used moves to `cli_args::script_override` and serves all six commands. `rb` becomes a clap variant of its own rather than a `visible_alias` of `rebuild`, because pnpm looks the overriding script up under the name that was typed, as pnpm 11 does with `argv.remain[0]`.
A redirected command behaves as `pnpm run <script>`, so the install-family `Done in ...` footer of the typed command no longer follows the script output. `script_override::resolve` records the redirect on the run context and `CliArgs::run` reads it.
`clean` dispatches its redirect through `dispatch_script::run` like the other commands, which gives it the pnpmfile `updateConfig` pass and the recursive handling it was skipping.
Separately, `dispatch_install::ci` dispatched its clean step through the overridable path, so a project with a `clean` script ran that script instead of emptying `node_modules` and the frozen install proceeded over a stale tree. `ci` is not an overridable command in pnpm 11 either, where `cleanInstall` calls the clean handler directly. It now calls the built-in unconditionally. That bug predates this branch and carries its own changeset.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Separate project lockfiles require loading each dependency hierarchy with
its own lockfile directory. Keep those typed results until the recursive
JSON renderer can serialize the complete array once. Joining independently
serialized arrays produces invalid JSON.
Reuse the existing loaders and renderers in both CLI implementations.
Extract the v11 typed listing loader for the recursive caller, and keep
Rust recursive orchestration in its own module within the file-size limit.
Resolve each project's modules directory in every output format of a
workspace whose projects keep their own lockfiles. Use the existing
project configuration helpers to apply packageConfigs without leaking
settings between projects, and look an entry up only under a name the
project declares. Preserve per-project error context during loading and
existing text formatting.
Add command-level regressions that parse complete stdout and verify
project-specific package paths and metadata. Canonicalize filesystem paths
in assertions so equivalent Windows separators do not fail the tests.
Closespnpm/pnpm#15011.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
The audit command already uses interactive mode to select vulnerabilities. When
`--fix=update` delegated to the update command, the same flag opened the normal
dependency picker even though vulnerability matching controlled resolution.
Disable interactive mode only for the delegated update.
Fixespnpm/pnpm#14927
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Historical Python checksum requests now use the configured retry policy.
Cache the validated GitHub release tags for 24 hours and refresh once when a cached list cannot locate the requested release. Bound neighboring release probes so a long run without a current-host build cannot turn into an unbounded serial scan.
Related to pnpm/pnpm#15060.
Replace Python's first-index search with explicit package namespace claims.
Normalize exact names and prefixes using Python package name semantics,
reject ambiguous claims, and select one index before fetching metadata.
Record canonical routing inputs in Python lockfiles so route changes
invalidate replay. Restricted registries do not implicitly enable PyPI.
Use local resolution for restricted or multiple routes because the pnpr
Python resolution protocol currently represents one whole index.
Reuse pnpr's package pattern validation and preserve existing npm scope
and prefix routing and Cargo's single-index configuration. Python support
is experimental, so release this change as a patch.
Make existing Cargo diagnostic assertions tolerate output wrapping and
isolate the import and pack plugin fixtures from user npm scope routes.
pnpm reads and validates `pnpm-workspace.yaml` before it switches to the
version `packageManager` pins, so a hard error at load time fires in the
pnpm that is on its way out. An unknown top-level key already accounts
for this: it is collected as a key issue and reported once the switch is
settled, and only by the pnpm that goes on to act on the file. A `tasks`
entry's unknown field did not, which left a project unable to adopt a
task setting its own pin understands until every contributor's global
pnpm was new enough to parse it.
Unknown task fields now travel that same path. pnpm 11 reached the same
conclusion for its own reader in pnpm/pnpm#15075; this is the half of
the problem that lives in the version the settings belong to.
A reported field is taken out of the settings, and an entry that carried
nothing else goes with it: a setting pnpm says it ignored must not come
back out of `pnpm config`, and an entry standing on one would declare an
empty dependency list where a task with no entry keeps its default
`^<name>` ordering. A report names only the first few fields and counts
the rest, because each path repeats the name of its task and a file
declaring one long name and many fields would otherwise render far more
text than it holds.
The other task checks are wrong in any version and still fail the load.
The global `config.yaml` drops its whole `tasks` section as
workspace-only, so a field inside one there never set anything and is
simply ignored with it.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The build-std check and the resolution-settings pass both walk the
ancestors of a Cargo workspace root for `.cargo/config[.toml]`, and both
read what they find with the containment-checked reader that guards the
files pnpm writes into the workspace. That reader opens with
`O_NOFOLLOW`, so a configuration above the workspace that is a symlink
fails the open with `ELOOP` and takes the whole install down. A
developer's own `~/.cargo/config.toml` is commonly a symlink into a
dotfiles repository, and it is an ancestor of every workspace under the
home directory.
Those files are Cargo's inputs, which pnpm only reads and the machine
alone controls, so both walks now share one reader that opens them by
path, the way Cargo does. The workspace's own file keeps the
containment-checked reader: pnpm writes that one, and a checkout must
not be able to point it at a file outside itself.
The shared reader hands back one configuration at a time, so the
build-std check still stops at the nearest file that answers and a
farther one it never needed cannot fail the run.
`PackageSpecifierPlan::parse` took a `&[AddRequest]`, so every selector
the npm add path reads as it was written was cloned on its way into the
plan. It now takes the requests by value and moves each one.
The other arms are untouched. Cargo, Python and each purl type build a
new selector out of the parsed components, so none of them ever needed
to own what it was handed, and a purl still mints its request through
`AddRequest::from_package_url`.
`IntoIterator` rather than a slice or a `Vec` because `parse` iterates
once, front to back, and never indexes or asks for a length. The one
production caller owns its vector and overwrites it on the next line, so
`std::mem::take` hands it over at no cost, and the test call sites pass
their array by value rather than by reference.
Counted with a global allocator: `pnpm add lodash@4 react@18
express@4.18.2` falls from four allocations to one, the one the
destination vector needs, and a lone `lodash@4` from two to one. The
purl and `crate:` / `pypi:` shapes are unchanged, and no shape
regresses.
Behavior is unchanged, so the test asserts a pointer rather than the
contents. A move leaves the heap buffer where it is, and nothing else
in the suite catches a clone creeping back in.
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gates of the repeat-install check refused every run in common Bit
workspaces, and one of them affects plain pnpm workspaces too.
modules_dirs_present treated a sibling project without node_modules as
never installed. Under dedupeDirectDeps a sibling whose every direct
dependency the root declares identically gets nothing linked, so the
linker never creates that directory; the sibling is installed all the
same. The gate now lets such a sibling through when the root's modules
directory exists and, for every alias the sibling declares in a group
the install materializes, the wanted lockfile records one target on
each side and the two agree, which is what the linker compares. link:
targets are resolved against each importer's directory; an alias a side
declares with differing targets across groups proves nothing, since the
linker picks one by group order and the check does not reproduce that;
a modules directory has to be a directory. The TypeScript checkDepsStatus
had the same gate and gets the same rule.
The merge-conflict scan of a changed lockfile refused files of 16 MiB or
more as unverifiable. Every changed lockfile that passes the scan is
parsed in full right after, so the size budget could only refuse what
the parse would read anyway; the scan now streams the whole file through
its fixed 8 KiB buffer. The symlink and non-regular-file refusals stay.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(config): read workspaces that use pnpm 12 task settings
`pnpm-workspace.yaml` is one format across pnpm 11 and 12, but the `tasks`
reader treated its two fields as an exhaustive allowlist and threw on
anything else. pnpm 12 defines six more (`concurrencyGroup`, `outputs`,
`inputs`, `env`, `cache`, `cargoTargetDir`), so every pnpm 11 command in a
workspace that declares one failed before it started.
Check only the fields this version reads and leave the rest alone, matching
how unrecognized top-level settings are already handled. pnpm 12 keeps
rejecting an unknown task field, since it is the version that reads them all.
Also name `cargo`, `concurrencyGroups` and `pipelines` in the
other-version table so the top-level warning stops offering a pnpm 11
setting as a spelling correction.
This repository's own `pnpm-workspace.yaml` started using `concurrencyGroup`
in pnpm/pnpm#15071, which broke `node pd.js --version` in TS CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changeset): drop the instead-of tail from the release note
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(config): report a task setting that misspells one pnpm 11 reads
Ignoring every unrecognized task field also ignored a misspelled one, and a
misspelled `dependsOn` is not inert: the entry still exists, so the task takes
the empty dependency list an entry without `dependsOn` declares and runs
before what it meant to wait for.
Reject a field that differs from `concurrency` or `dependsOn` only in case.
No pnpm version declares two settings that close, so such a field is a typo,
and the check needs no list of the settings other versions read.
Cover the pnpm 12 entries of the other-version table, which reach users as
warning text and had no test.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repository is checked out into many worktrees at once, and a second
Rust build or TypeScript test run on one machine only takes cores and
memory from the first. The TypeScript test scripts additionally share
the `../pnpm_tmp` scratch directory across worktrees and wipe it on
startup, so two of them cannot overlap at all.
Only root-only script names carry a group. An entry for a name a
workspace package also defines would drop that task's default
`dependsOn: ['^<name>']` ordering and take a slot per project,
serializing every recursive run of it.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Discover Python projects from shallow paths to deep ones.
Each uv workspace declaration controls which descendants pnpm reads.
Outside declared workspaces, skip conventional sample and documentation directories.
Template and test-fixture directories are skipped too.
An explicit workspace member can still include any of these directories.
Closespnpm/pnpm#15058.
Keep the latest checksum manifest as the common path. When an exact patch is
absent, binary-search python-build-standalone release tags and cache the
immutable checksum manifest used to find it.
Closespnpm/pnpm#15060
* fix(cli/add): name the ecosystem an add refuses, not the prefix
`pnpm add pkg:cargo/serde --global` was refused with "crate: dependencies
cannot be installed globally", a prefix the command line did not carry.
A Package URL reaches every one of these checks, so the messages name the
ecosystem they are about instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(resolving-npm-resolver): reject an alias that shadows a prefix in any case
A named-registry alias was compared to the reserved prefixes exactly,
while a specifier's prefix is read case-insensitively wherever the
notation it comes from is. A URL scheme is, so `PKG:npm/lodash` is a
Package URL, and a registry named `PKG` was accepted at configuration
time and then unreachable: every selector that named it was read as a
malformed purl instead.
An alias is now rejected whatever case it is written in.
`shadows_reserved_version_prefix` is separate from
`is_reserved_version_prefix` because reading a dep path back must keep
matching the prefix pnpm wrote, exactly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(cli/global): cover the package a global purl installs
The mark a Package URL carries reaches the global path through the group
each request splits into, and nothing failed if it stopped arriving: the
purl tests all ran project installs, so dropping the mark there would
have left them green while `pnpm add -g pkg:npm/node@22.0.0` went back to
installing the Node.js runtime.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(deps-path): cover the case policy each reserved-prefix check takes
The two checks differ only in case sensitivity, which is the whole point
of having both, so a test pins each side: an alias shadows a prefix in any
case, and a dep path's version slot still reads `PKG:1.0.0` as a named
registry because that is the prefix pnpm wrote.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(deps-path): reserve a mixed-case alias only where the prefix is read that way
Folding the case of every reserved prefix rejected aliases that worked: a
`npm:` specifier is read exactly, so `Npm:lodash` names a registry called
`Npm` and that entry resolves. Only a prefix a selector may spell in any
case has to be reserved that way, which today is the Package URL scheme.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(cli/add): cover every refusal that names an ecosystem
Four of the six refusals had no assertion on their text, and none had a
Package URL case, which is the spelling that motivated the wording. Each
is now asserted in both spellings.
The changesets say what pnpm does and stop there. One of them appended the
old behavior and the other explained why the entry was unusable, both of
which belong in the commit body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(cli/add): drop a comment the refusal table already says
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(python): install wheels with bad RECORD hashes
Treat wheel RECORD as a structural manifest. The locked archive hash remains the integrity boundary.
Recompute hashes for the installed RECORD from imported contents.
Closespnpm/pnpm#15061
* test(python): accept mismatched RECORD hashes
* fix(python): reject malformed wheel records
Keep validating RECORD hash and size syntax without comparing the declared values to file contents.
* perf(python): stream installed RECORD hashes
Hash imported wheel files in bounded chunks and stream Copy-mode imports to their destinations.
* test(python): verify streamed RECORD digest
* perf(napi): take the repeat-install fast path with in-memory manifests
The Node-API bindings disabled both repeat-install short-circuits, because
the workspace-state check judged manifest freshness by the package.json
mtime, which says nothing about a manifest the caller supplied in memory
and may not exist on disk at all (a Bit component directory carries no
package.json). Every repeat install therefore ran the full frozen path,
re-copied every file: directory snapshot, and reported them as added, so
the embedder saw dependencies change on every run.
Add ManifestFreshness to the check. Mtime keeps the CLI behavior. Content
skips the stat and compares every importer with the wanted lockfile by
content, which is the authoritative check the mtime shortcut only avoids.
The bindings select Content and re-enable both short-circuits for
installs; peer-issue queries stay excluded because they exist to resolve.
The wanted-vs-current assertion in the content check now uses the
reachable-shape comparison the frozen-tree gate already used: the current
lockfile records only what the importers reach and none of the top-level
keys pnpm does not define, so a wanted lockfile with an unreachable
snapshot or an embedder's extension block was never equal to it and kept
forcing a full install. The skipped reason is now logged at debug level.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* chore: address the first review round
Keep the changeset user-facing, drop a test doc comment that narrated the
test's own setup and assertions, and require the content-mode check to
advance the recorded validation timestamp rather than merely hold it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: state the plain lockfile policy's contract instead of its fields
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
`pnpm add` reads a `pkg:` selector as the Package URL specification
defines it and routes it to the ecosystem its type names: npm to
package.json, cargo to Cargo.toml, and pypi to pyproject.toml. Each
purl is rewritten into the selector that ecosystem's own protocol
already takes, so it inherits that path's flags, defaults, and errors
instead of growing a second set.
Every decoded component is validated before the pieces are joined back
together. A name followed by a spec is how pnpm spells an npm alias, so
a name or a version carrying a decoded at-sign or colon would otherwise
redirect the install to another package. Qualifiers and subpaths are
rejected rather than dropped: a repository_url qualifier or a subpath
that pnpm ignores installs something other than what the selector names.
AddArgs::package_names now holds the selectors the npm add path receives
rather than the ones argv carried, which is what lets a purl reach the
--global and --config forms as well. AddPipeline keeps only the selectors routed
to another ecosystem, so the split has a single home, and
dispatch_install::add delegates that split to a named step to stay
within the local-binding limit perfectionist enforces.
A purl always names a package to install. pnpm add node@22.0.0 records a
runtime and pnpm add npm@11.0.0 records the project's package manager,
both from a bare name a person typed, so the package-manager and runtime
recognition skips a selector a purl was rewritten into.
That mark belongs to the request rather than to its text. package_names
holds AddRequest values, each carrying its own selector and whether a purl
was rewritten into it, so two requests that normalize to one selector keep
their own meanings and no second list has to be matched against or kept in
sync.
A rejected selector is quoted back by what it decodes to, because an
encoded separator would otherwise hide a user:pass@ authority from a check
made on the raw text. What decoding leaves unreadable to that check is
stopped at instead: a colon followed by an at-sign is how a URL spells
userinfo, and nothing pnpm installs pairs the two past its protocol, so no
list of the characters that can sit between a scheme and its slashes has to
be complete.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
A `namedRegistries` alias addressing a URL that `registries` serves to
another ecosystem is dropped, and a later layer's alias meets an index
role an earlier layer declared: only `registries` can retract that role,
so the alias loses there too. Covered by its own test now.
`add_python_index` is renamed to `add_python_index_searched_first`, which
carries what its doc comment was there to say, and the comment on
`write_index_credentials` keeps only the part the code cannot say: the
setting refuses a credential, so the `.npmrc` is where one lives.
Follow-up to review comments on https://github.com/pnpm/pnpm/pull/15054
pnpm/pnpm#15043 gave the programs pnpm downloads one setting. This does the
same for the registries it downloads packages from, which were three
settings sharing neither a name nor a shape: `registry` / `registries`,
`python.indexUrl` with `python.extraIndexUrls`, and `cargo.indexUrl`.
A `registries` entry now names the ecosystem it serves:
registries:
https://internal.example/simple/:
ecosystem: pypi
https://pypi.org/simple/:
ecosystem: pypi
https://index.crates.io:
ecosystem: cargo
`ecosystem` is absent on an npm registry, so every entry written before
there was anything else to serve means what it did.
An ecosystem with several indexes searches them in the order declared, and
the first index that has a package supplies it. `registries` is an
`IndexMap` for that reason: `python.extraIndexUrls` was a list, so a
URL-keyed `BTreeMap` would have handed the search order to URL spelling.
uv marks one index `default = true` and gives it lowest priority wherever
it sits; pnpm reads position instead, because one rule is easier to hold
than two.
A URL serves whatever the last layer to declare it says, and loses every
role an earlier layer gave it. Each layer's `registries` is validated on
its own, so without that rule one URL could be an npm registry to the
machine and a PyPI index to the repository at once.
Credentials are the point. `registry`, `cargo.indexUrl` and
`tools.node.mirror` all resolved credentials by origin from the machine's
auth sources; `python.indexUrl` read them only from an inline `user:pass@`
in the URL. A private PyPI index was therefore the one package source in
pnpm whose password had to be written into the committed
`pnpm-workspace.yaml`. `registries` refuses a credential in a key or a
field, so moving Python indexes there settles it the way it is settled
everywhere else, and deletes the machinery Python had grown for the inline
form: the `IndexAuth` route hook, `validate_index_credentials`, and
`ERR_PNPM_CONFLICTING_PYTHON_INDEX_CREDENTIALS`.
One behavior change follows from that. The old code picked a credential by
longest-matching declared index, so a credentialless index below another
withheld its parent's credentials. npm matches on auth entries instead, so
a credential configured for `//host/simple/` reaches
`//host/simple/public/`, and the machine narrows it by configuring the
narrower path. The test named for the old rule is replaced by one asserting
what this model guarantees: a credential does not travel to an index on
another origin.
The word and its values are the repository's own: pnpr's registry config
already spells `ecosystem: cargo` and `ecosystem: pypi`. The enum is not,
because pnpr's carries `oci` too and pnpm installs from no OCI registry, so
accepting it would be a value pnpm parses and then refuses further in. The
two agree on the wire.
`python.indexUrl`, `python.extraIndexUrls` and `cargo.indexUrl` have not
been released, so they are gone rather than deprecated.
Related to pnpm/pnpm#14945
perfectionist's `cloning_getter` (KSXGitHub/perfectionist#410) and
`overly_complex_condition` (KSXGitHub/perfectionist#409) both landed
upstream, so the pinned revision moves to the one that carries them and
the workspace comes under both rules at once.
`TestRegistry::url` and `PnprBenchmarkRegistryOverride::resolve_registry`
return `&str`, and the call sites that keep the value copy it there,
where the reader can see the copy. Most do not: they interpolate the URL
into a request or a fixture path. `EmulatedCancellation::receiver`
becomes `to_receiver` instead, since its one caller stores the receiver
in the run it builds, so the copy is the method's business rather than
the call site's. The test helpers that only forwarded a registry URL into
`config.registry` now take `&str` and copy once inside.
Conditions are capped at five `&&`/`||` operators, the point past which a
guard's clause list stops reading as one predicate. Five conditions were
over it. Each one either names the concept its clauses decide
(`is_valid_repository_path`, `snapshot_is_reusable`) or folds the clauses
that share a shape into one pattern. `frozen_tree_up_to_date`'s
eleven-operator `let` chain becomes a sequence of guard clauses, each
carrying the comment that explains the gate it holds, with the build
checks in a helper of their own.
Both caps are documented in the Rust code style guide.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep init pinning metadata in the fixture cache even when a test replaces
its workspace YAML. Reuse the mtime helper after staging an env-document
conflict so the repeat-install check sees the edit.
Closespnpm/pnpm#15052.
python.pythonVersions repeated the table it sits under, so it read as
"python python versions". The table already says which ecosystem the
versions belong to, and the setting is now python.versions.
supportedVersions was the other candidate, for symmetry with
supportedArchitectures. It is not used: pyproject's requires-python is
what says which versions a project supports, pnpm reads it, and it
bounds what this setting may name. Naming the bounded thing after the
bound invites the two to be read as one. The two settings are not
adjacent in the file either, so the symmetry would rarely be seen.
The pylock.toml tool.pnpm key stays python-versions, and the internal
names that feed it with it. Inside a lockfile the key sits beside
platforms, where versions on its own says nothing about what is
versioned.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
pnpm downloads a Node.js runtime, a Bun runtime, a Deno runtime, a Yarn
release and, since this week, a Python interpreter. Two of those could be
pointed at a mirror, through two settings that shared neither a name nor a
shape: `node-mirror:<channel>` and `python.downloadUrl`. Bun and Deno had
none at all.
`tools` names them, keyed by the tool, with `mirror` saying where its
builds come from:
tools:
node:
mirror: https://mirror.example.com/node/download
bun:
mirror: https://mirror.example.com/bun
python:
mirror: https://mirror.example.com/python-build-standalone/releases
The key is the tool itself, with no category above it. Every taxonomy
this could have used breaks on its next entry: Node.js is a runtime, Yarn
is not, a Python interpreter arguably is, a Rust toolchain would not be,
and a cargo subcommand certainly is not. `tools` inherits no such split
and needs no rename when the next one arrives. It is also the word both
tools that manage many runtimes use, in proto's `[tools.*]` and mise's
tool-namespaced settings.
A mirror carries the project's own layout below it, so only the host
above it differs. That is what lets one string serve tools whose trees
are otherwise nothing alike, and why `tools.node.mirror` is the base its
release channels hang off, exactly as nodejs.org lays them out.
This is the tool an ecosystem runs on, not where its packages come from.
`registry`, `python.indexUrl` and `cargo.indexUrl` keep answering that,
which is why `tools.python` and the `python` section can mean different
things without competing.
`python.downloadUrl` has not been released, so it is gone rather than
deprecated, and its pending changeset now names the setting that replaces
it. `node-mirror:<channel>` has shipped and stays: it names one release
channel where the base names them all, so it decides the channel it names
and leaves the rest to the base.
Deno and Yarn are left out. Both read the GitHub API for release metadata
and take asset URLs out of the response, which a base URL cannot stand in
for. pnpm/pnpm#15042 covers the URL rewriting that would reach them.
`pnpm pack-app` also downloads the Node.js it embeds through `tools.node`
now. It passed no mirror at all, under a comment saying pacquet had no
such setting, which stopped being true when `nodeDownloadMirrors`
landed. It reads only `tools.node`: that runtime runs as the builder and
is kept in the shared pack-app cache, so a repository naming
`node-mirror:<channel>` would be choosing the program that packs
everyone's app.
Related to pnpm/pnpm#14945
The pre-commit formatter rewrites the file it is given, and accepted a
staged path whenever its last component was a regular file. That is not
enough to know the bytes it rewrites belong to the commit.
rustfmt truncates rather than replaces, so the inode survives and every
other name for it is rewritten too. Git stages a hardlink like any other
file and reports the tree clean, so an in-checkout `.rs` link to a file
anywhere on the machine reached the formatter, and formatting the commit's
own file rewrote the other one, with nothing in `git status` or the diff to
say so.
The paths that resolve out of the checkout were not reachable: git refuses
to index one beyond a symbolic link, and reports one whose directory became
a link as deleted from the working tree, which the unstaged check already
holds back. The rule is the script's now rather than a consequence of those
behaviors and the order of two checks.
A staged path is formatted only when it names one regular file inside the
checkout, and one that leads nowhere is named rather than fatal: `ENOTDIR`
and `ELOOP` used to escape as a stat error and abort the commit. The
formatter receives the resolved path, so it does not walk the links again;
the window between the check and the open stays open, as Node offers no way
to open a path one component at a time.
Follow-up to https://github.com/pnpm/pnpm/pull/15045, which merged before
the review comments this answers.
Three papercuts that together make a lint cost a full pre-push sweep.
`test-affected` took its diff against the local `main`, which is only as
current as the last time someone checked it out — and `main` usually lives
in another worktree here, so it lags. Every commit it lagged by read as a
change of the current branch: crates someone else touched got tested, and
a file every crate compiles against, landing upstream, made the whole run
refuse to scope and send you to `just ready`. Six commits of lag was
enough to attribute an unrelated `Cargo.lock` to a branch that had not
touched it. A `--base` that names a branch now diffs against that
branch's remote-tracking ref. A tag, a revision expression, a qualified
ref and `HEAD` are taken as given, since each already names one commit.
The pre-commit hook formats the Rust files being committed, with the
pinned rustfmt. Formatting is the one Rust check that needs no compile, so
it is the one that can run per commit; clippy and dylint stay in pre-push
where their cost is paid once. A file with unstaged changes as well is
left alone and named, since formatting the working tree and staging the
result would commit the part of it the author held back. The pathnames are
handled in Node, one argument per file, so a path holding a space, a glob
character, or a leading `-` reaches rustfmt intact, and only a regular
file is formatted: rustfmt writes through a symlink.
Both lint failures in pre-push named the command that reports the
findings rather than the one that applies them. `just fix` and the new
`just dylint-fix` fix most of what either finds, which is the difference
between one more sweep and several.
The doc named the collision the tags were added to prevent, which
describes the encoding they replaced rather than the one in the code.
Raised in review of pnpm/pnpm#15013 by Qodo.
`supportedArchitectures` stood for the cross product of its `os`, `cpu`
and `libc` axes, which cannot express the platform set a workspace
usually ships: Linux x64, macOS arm64 and Windows x64 is three platforms,
but as axes it is six, including the three nobody asked for. The setting
now also reads as a list of the platforms themselves.
An entry parses either as pnpm's own `<os>-<cpu>[-<libc>]` spelling, the
one `pnpm pack-app --target` already takes, or as the Rust target triple
of the same machine, the one uv and the rest of the Python tooling use.
Both canonicalize to one value, so two spellings of a platform are one
platform rather than two. On Linux the C library field also carries a
Python wheel ABI baseline, as in `linux-x64-manylinux_2_28`, which is the
Python-specific dimension the axes have nowhere to put. A Linux platform
that names no C library is the glibc platform, as `linux-x64` means
wherever else it is written.
This lets `python.platforms` go. It named the same thing in a spelling of
its own, in a per-ecosystem setting, while the setting that says which
platforms an install prepares for was already there. `pylock.toml` is now
resolved for the platforms `supportedArchitectures` names, so a workspace
declares its platforms once for its npm and its Python dependencies both.
The axes keep their meaning for a project that writes them, Python
included, where they stand for every platform they cross into.
The optional-dependency check judges a listed platform as a whole rather
than an axis at a time: `[linux-x64, darwin-arm64]` refuses a package
built for darwin x64, where the axes take it because each axis matches on
its own. The dlx cache key and the runtime-archive selector read the new
form too, the latter preferring the listed platform this machine is over
mixing one axis with another.
`python.platforms` and `python.pythonVersions` have not been released, so
this reuses their pending changeset rather than adding a migration.
Related to pnpm/pnpm#14945