Commit Graph
12645 Commits
Author SHA1 Message Date
Zoltan Kochan f31c7ca00a perf: prune newly ignored optional dependencies without resolution (#13482)
Changing ignoredOptionalDependencies previously forced dependency resolution
even when the change only added ignore patterns. Detect monotonic additions,
remove matching optional importer edges, and prune package snapshots that are
no longer reachable. Preserve packages that remain reachable through another
dependency path.

Use the same optimization in the TypeScript CLI and pacquet. Keep removals and
new exclusion patterns on the safe resolution path, and prevent pacquet from
using a pruned lockfile as the seed when ignored-optional settings differ.

Make current-version package-manager pin tests use a static local registry so
release commits can be tested before their version is published.

Related to pnpm/pnpm#13474.
2026-08-06 12:40:27 +02:00
Ayush SinghandZoltan Kochan ecd540b324 fix: make pnpm version -r --json emit JSON when there are no pending changes (#13222)
With --json, "pnpm version -r" must emit machine-readable output in every
case. The no-pending-changes branch printed a human-readable sentence to
stdout; it now prints an empty JSON array. The applied-releases branch in
pacquet now serializes the AppliedRelease list as camelCase JSON, matching
the TypeScript CLI's output shape exactly.

Fixes pnpm/pnpm#13217.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-08-06 11:48:51 +02:00
Felipe PletsandZoltan Kochan f2fa9c953b fix(resolver): treat an empty version range as "*" (#13674)
An omitted version range means "any version" in JS semver, but the Rust
port classified it as a dist-tag: Version::parse and Range::parse both
reject "", and is_valid_dist_tag("") is vacuously true. The picker then
asked the registry for a tag that cannot exist, so any manifest
publishing an empty range -- js-xlsx, codepage and ssf all do -- failed
to install.

Add a semver_range shim to resolving-resolver-base modelling the two
readings of semver.validRange that pnpm relies on, and route each call
site to the one its TypeScript counterpart uses.

is_any_version_range models the loose reading, validRange(x, true) ===
"*", which is the mode version-selector-type runs in. It answers before
Version::parse and Range::parse in both selector classifiers, so an
omitted range normalizes to "*" instead of falling through to the
dist-tag branch. Running first also fixes "^1.0.0 || ", which
Range::parse otherwise accepts and silently narrows to "^1.0.0". The
same gap had been dropping empty specs out of the preferred-versions
tie-break table.

is_valid_semver_range models the strict reading, validRange(x) != null,
under which one unparsable comparator set invalidates the whole union.
It replaces the two Range::parse(..).is_ok() checks that disambiguate a
version range from a package name. These cannot use the loose reading:
"npm:is-negative@^2 || " would take the range branch, dropping the inner
name so the outer alias becomes the package name, and pnpm would install
whatever is published under it.

Verified against pnpm 11.20.0 rather than by reading source: the
empty-range repro and the union case both produce byte-identical
lockfiles.

Fixes pnpm/pnpm#13673.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-08-06 11:34:23 +02:00
Minha KangandZoltan Kochan 3b85f077b5 fix(deps-installer): skip no-save updates to versions the kept range excludes (#13527)
pnpm update <pkg>@<version> --no-save applied the requested version to
every selected importer, including importers whose declared range
excludes it. The manifest keeps its specifier in that flow, so the
lockfile ended up recording a version that contradicts its own
specifier (e.g. specifier ^6.0.0, version 7.8.5) and the next
pnpm install --frozen-lockfile rejected it with
ERR_PNPM_OUTDATED_LOCKFILE. Renovate drives exactly this command shape
in workspaces where two importers require different majors.

Only apply a versioned selector when every version it allows stays
inside the kept range (semver.subset — an overlapping range such as
">=6" is not enough, resolution could still pick a version above the
kept range); skip the dependency in that project with a warning
otherwise. Updates that save are unaffected: they rewrite the manifest,
so the lockfile stays consistent.

The Rust CLI had the same acceptance plus a second defect: it writes
the requested specifier into the lockfile importer entry while
package.json keeps the old one, so even an in-range no-save update
produces a lockfile that fails the manifest-sync check. The same
subset gate now guards the selector rewrite
(node_semver::Range::allows_all); the in-range specifier leak needs a
channel separating resolution wants from the persisted specifier and
is tracked in #13526.

Fixes #12764

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-08-06 11:31:24 +02:00
Zoltan Kochan 2d10d1f88c docs(security): state the untrusted-repository trust boundary (#13601)
* docs(security): state the untrusted-repository trust boundary

The threat model section only described the filesystem-permissions tier,
so it was silent on the tier that reports actually target: a repository
the user clones and then runs pnpm inside.

pnpm defends part of that tier — a plain install must not send
credentials to a repository-chosen host, and must not run unapproved
dependency build scripts — which is why environment variable expansion
in repository-controlled configuration is gated for request destinations
and auth values. Because that gate is real but partial, a setting
missing from it reads as an oversight rather than as a setting that was
never in scope. A recent advisory reported exactly that about
`nodeOptions`, which only takes effect for `pnpm run` and `pnpm exec` —
commands that execute repository-chosen code regardless, and which
honor a literal value with no placeholder at all.

Name the boundary, list the two out-of-scope classes it implies, and ask
reporters of gate-coverage gaps for the counterfactual: what the gap
buys an attacker beyond what they could already do without it.

* docs(security): correct what a plain install defends

The previous wording claimed installing from an untrusted repository is
a defended act, distinct from running its code. It is not: a plain
`pnpm install` runs the project's own preinstall/install/postinstall/
prepare scripts, gated only by `ignoreScripts` (default false) at
installing/deps-installer/src/install/index.ts. The build approval
mechanism never reaches runLifecycleHooksConcurrently — `allowBuild` is
passed to buildModules and `_rebuild` only, so it gates dependencies'
build scripts and not the installed project's own.

Describe the env expansion restrictions as what they are: hardening that
narrows silent credential exfiltration in the --ignore-scripts case and
in review workflows where a config diff draws less scrutiny than a new
postinstall script, rather than a boundary whose coverage gaps are
vulnerabilities by themselves.
2026-08-06 11:26:29 +02:00
Zoltan Kochan cba0df1797 feat(napi): add allowUnusedPatches install option (#13677)
pnpm fails an install when a configured patch matches no installed
package (ERR_PNPM_UNUSED_PATCH). The CLI lets a workspace opt out via
allowUnusedPatches in pnpm-workspace.yaml, but the binding accepted
patchedDependencies only, so an embedder that ships its own patches had
no way to reach the setting.

Threads the flag through InstallOptions -> ConfigOverlay ->
Config::allow_unused_patches, which the fresh-lockfile path already reads
when it calls verify_patches. No engine-side change is needed; this is
purely the missing binding surface.

Bit needs it to ship a patch keyed to a version range
(`@teambit/react.react-env@1`) from its own config: workspaces that
resolve 2.x match no key and would otherwise fail the install. See
teambit/bit#10572.
2026-08-06 10:42:20 +02:00
Zoltan Kochan 74577d73fa fix(deps-restorer): materialize each global-virtual-store slot once (#13669)
The link pass spawned one CreateVirtualDirBySnapshot per snapshot key.
Under the global virtual store, peer variants of a package whose
recursive dependency hashes are equal map to one slot directory - that
sharing is the point of the hashing - so hash-equal variants raced
concurrent imports against a single path. Immutable packages tolerate
this (their import is marker-gated and safe to skip), but a mutable
`file:` source forces a stage-and-swap, whose remove-then-rename
assumes an exclusive owner: one worker's `remove_dir_all` interleaves
with another's rename and the install fails with `Directory not empty`
or `NotFound` depending on timing. Injected workspace packages in a
peer-heavy monorepo hit this on every install.

pnpm v11 never faces the race because `lockfileToDepGraph` keys its
graph by directory, so one node per location survives graph
construction. Restore that invariant here by grouping slot links by
`slot_dir` before the rayon pass: the first variant materializes the
directory, the rest only report progress, and obsolete-child aliases
recorded against any variant are unioned so stale links still get
removed no matter which variant runs. Without the global virtual store
every group is a singleton and the pass is unchanged.
2026-08-06 09:55:17 +02:00
Zoltan Kochanandgithub-actions[bot] b0e819458a chore: update dependencies, Node.js, pnpm, and GitHub Actions (#13668)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-05 23:57:22 +02:00
Zoltan Kochanandgithub-actions[bot] b286ddbfbf chore(release): pacquet 12.0.0-rc.0 (#13667)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
v12.0.0-rc.0
2026-08-05 21:42:03 +02:00
Zoltan Kochan 6e39ea3def chore(release): move the Rust CLI to the rc lane (#13666)
Switch pacquet and its fixed-group partner `@pnpm/napi` from the beta lane
to rc, so the next "pnpm version -r" cuts 12.0.0-rc.N prereleases instead
of continuing the 12.0.0-beta.N series. pnpr stays on alpha.
2026-08-05 21:39:46 +02:00
Zoltan Kochan 34347bc706 fix(napi): keep registry-custom fields in a fullMetadata resolve (#13664)
`resolveDependency` derived `filter_metadata` from `full_metadata`, so a
caller asking for full metadata got a manifest run through `clear_meta` —
reduced to npm's abbreviated version fields. Registry-custom fields were
dropped, which broke Bit's `componentId` lookups ("the package.json of
version X has no componentId field").

The API this replaces, `createClient({ fullMetadata: true })`, leaves
`filterMetadata` unset and keeps the document whole; only the install
path pairs the two flags, where nothing outside the abbreviated set is
read. Untangle them here.
2026-08-05 17:13:05 +02:00
Edward Long 21429c6f66 fix(store.path): keep the store inside the project when nothing above it is linkable (#13536)
`dirsAreEqual` compared the result of `path.relative()` with `'.'`, but
Node returns `''` for equal paths, so both equality checks in
`storePathRelativeToHome` were always false. The dead branch would have
placed the store in the home directory whenever the project folder was
the only linkable location.

Executing that branch would be the wrong trade. It fires exactly when no
ancestor of the project accepts a hard link — an agent sandbox that
grants write access only to the project, or a container with just the
project bind-mounted writable. There the home store is either read-only,
so the install fails outright, or on another volume, so every package is
copied instead of hard linked and nothing is ever reused. A store on the
project's own volume is the better answer in both cases, so the branch is
removed rather than repaired, and the surviving root guard drops the
helper for a plain string comparison of two `path.join` results.

pacquet implemented the intended-but-dead branch, so it inherited both
failure modes; it now keeps the store in the project too. It places the
store at `<project>/node_modules/.pnpm-store` instead of pnpm 11's
`<project>/.pnpm-store`, which keeps it out of the working tree's VCS
status. The install purge already skips hidden entries other than `.bin`,
`.modules.yaml`, and the virtual store dir, so a layout change cannot
wipe the store; `test_install_purges_node_modules_on_layout_mismatch` now
pins that.

Closes pnpm/pnpm#13525
2026-08-05 16:45:22 +02:00
Zoltan Kochan bdb25eed7d feat(cli): warn on global operations under sudo, refuse them in v12 (#13495)
pnpm keeps global packages and configuration in the invoking user's
home directory, so 'sudo pnpm add -g', 'sudo pnpm setup', and
'sudo pnpm self-update' never do what the user wants: they target
root's home and leave root-owned files behind. Other package managers
settled on the same direction - npm 9 removed its SUDO_UID chown
machinery (npm/cli#5710) and tells users not to use sudo, and Homebrew
refuses root outright.

Both stacks detect the same situation: effective uid 0, SUDO_USER
naming a non-root user, and a command that mutates home-directory
state - setup, self-update, or a global write (add, remove, update,
runtime, approve-builds, bare link, config set/delete resolving to
the global scope). They differ only in severity, because refusing
these commands is a breaking change: pnpm v11 warns and names the
v12 removal, so existing provisioning scripts keep working, while
pacquet (v12) fails with ERR_PNPM_SUDO_NOT_SUPPORTED. Read-only
global commands (bin, root, prefix, list, outdated, config get, ...)
still work, and plain root sessions without SUDO_USER are unaffected.

The TypeScript check lives in pnpm11/pnpm/src/checkSudo.ts and runs
once the reporter is initialized, so the warning is not published to
a bus nobody is subscribed to yet. The pacquet check lives in
crates/cli/src/cli_args/sudo_guard.rs and runs before the
pre-command package-manager switch, so pnpm never delegates to a
downloaded CLI as root either.

Related to pnpm/pnpm#13096.
2026-08-05 14:19:37 +02:00
Zoltan Kochanandgithub-actions[bot] feaf9e1ba9 chore: update dependencies, Node.js, pnpm, and GitHub Actions (#13654)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-05 14:07:19 +02:00
Zoltan Kochan 769fad56d8 refactor(deps-restorer): split the build phase into its parts (#13662)
Split the 1617-line `build_modules.rs` into `allow_build_policy` (build-script permission and dep-path key parsing), `build_one_snapshot` (a single package's build), and `slots` (virtual-store slot location and repair), leaving orchestration in the root at 590 lines. Pure code motion.
2026-08-05 13:55:28 +02:00
Zoltan Kochan 8753f7dd90 fix: materialize and hash link: dependencies in virtual-store slots (#13658)
Materialize linked snapshot dependencies inside pacquet's virtual-store slots so packages remain self-contained when those slots live outside the project.

Model each resolved link target as a leaf in the TypeScript and Rust dependency graphs. This makes a changed link target affect the recursively computed slot hash of every ancestor while allowing projects that resolve to the same target to keep sharing a slot. Keep the graph extension limited to global-virtual-store hashing so side-effects-cache keys retain their existing behavior.

Thread optional-dependency selection through pacquet's slot materialization and inspect warm slots before reuse. This removes stale optional links under --no-optional and restores them when optional dependencies are enabled again. Preserve transitive optional dependencies during partial installs, where a narrowed direct dependency-group selection does not express --no-optional intent.

Pin the normalization contract with a fixture consumed by both stacks, covering empty targets, parent segments, and Windows drive-letter casing and asserting the final slot hash as well as each synthetic node. Treat missing, dangling, and non-directory expected optional children as a reuse mismatch, detect dangling unexpected entries too, and surface other metadata errors. Run optional-child scans only after the slot passes the cheaper existence and integrity checks.
2026-08-05 13:54:47 +02:00
Zoltan Kochan 1a75bb2434 refactor(package-manager): split the optimistic repeat-install check (#13661)
Split the 1899-line `optimistic_repeat_install.rs` into per-subject probe modules (`manifest_agreement`, `settings`, `deps_status`, `local_file_deps`, `timestamps`, `conflict_markers`), leaving the decision itself at 601 lines. Pure code motion; the crate's public surface is unchanged.
2026-08-05 12:48:15 +02:00
Zoltan Kochan a92dc94042 refactor(deps-restorer): split the frozen install into its subsystems (#13659)
`install_frozen_lockfile.rs` carried two subsystems that share nothing
with the frozen install beyond being reachable from it:

- `build_phase` — running package build scripts once the tree exists
- `hoisted` — the flat-`node_modules` linker, its hoist plan, and the
  runtime-major probes that feed it

Both move out, leaving the frozen install itself — its inputs, error
type, and output — at 930 lines rather than 1830.

Re-exports are explicit and split by the visibility each item already
had, so the crate's public surface is unchanged and internal helpers
stay internal.

Pure code motion: no behaviour change, verified by comparing every
content line of the original against the union of the new modules.
2026-08-05 11:55:07 +02:00
Zoltan Kochan 29b6da6b7c refactor(cli): split the audit command into per-concern modules (#13657)
`cli_args/audit.rs` held four unrelated jobs in one file: the command
itself, the registry wire format, the dependency graph sent for audit,
and everything the `--fix` flag does. Split along those lines:

- `report` — the audit response and the report derived from it
- `request` — turning a lockfile into the graph the registry audits
- `paths` — the dependency paths reported with each advisory
- `render` — the JSON and text renderings
- `version_ranges` — the semver questions asked of advisory ranges
- `fix` — overrides, ignores, and dependency updates

`audit.rs` keeps the argument types and the command body, at 2126 lines
down to 624. Re-exports are explicit rather than glob, and cover only
what is used outside the owning module; the tests now import each item
from the module that defines it.

Pure code motion: no behaviour change, verified by comparing every
content line of the original against the union of the new modules.
2026-08-05 11:06:15 +02:00
Zoltan Kochan 420f91baee fix(tarball): treat \ as a separator in archive entry paths (#13655)
A published archive can spell an entry path with backslashes. On Unix
those are ordinary filename characters, so the traversal check — which
walks `Path` components — saw `..\..\evil.txt` as one `Normal`
component and stored it verbatim as a CAFS key. The key then travels
through the `index.db` shared with pnpm to a reader that does treat
backslashes as separators.

pnpm folds `\` to `/` before validating (`parseTarball.ts`), so the fix
is the same fold rather than an outright rejection: an archive built by
Windows tooling that spells a nested path `bin\tool.js` installs under
pnpm and now installs here, under the same `bin/tool.js` key.

`archive_entry_segments` does the fold and the escape check for both
container formats. The zip path already claimed to reject smuggled
backslashes; `enclosed_name` only guarantees `Normal` components, which
says nothing about what is inside one, so it did not.
2026-08-05 10:20:33 +02:00
Zoltan Kochan 1ee45b95ba refactor(tarball): split the crate root into per-concern modules (#13652)
Split `pacquet-tarball`'s 3251-line crate root into per-concern modules (`error`, `download`, `extract`, `zip_archive`, `prefetch`, `local_tarball`), leaving the root with the shared statics, type aliases, and resolution-time entry point. Pure code motion; the public API is unchanged.
2026-08-05 09:31:55 +02:00
Zoltan Kochan a550eca68a fix(pacquet): make the global virtual store safe to share, and reachable from the Node API (#13648)
The global virtual store is shared across projects, so concurrent installs may materialize the same content-addressed slot. The existing swap removed an occupied target before moving its staged copy into place. This could race with another writer, fail with a directory-not-empty error, or briefly remove a directory another process was reading.

Add a safe-to-skip import mode for immutable global virtual-store slots. It first attempts a non-destructive rename. If another importer already completed the target, discard only the staging directory. Incomplete targets still use the repair path. Mutable directory sources do not use this mode because a complete slot may still contain stale files, and the transient needs-build marker is excluded from completeness checks so completed build output is preserved.

Expose enableGlobalVirtualStore, globalVirtualStoreDir, packageExtensions, and patchedDependencies through @pnpm/napi. Resolve relative patch paths from the requested install directory, recompute global virtual-store derivation after applying the overlay, and return both configured and effective virtual-store paths from readConfig.
2026-08-05 02:01:06 +02:00
Zoltan Kochan a540b24e99 refactor(deps-restorer): split create_virtual_store, and fix stale file: dependency copies (#13649)
Split `create_virtual_store::run` into per-phase submodules, and fix `file:` dependencies not being re-copied when their source directory changed. Both the optimistic repeat-install short-circuit and the import completion-marker check treated an unchanged lockfile as evidence that a mutable-source slot was current.
2026-08-05 01:08:40 +02:00
Zoltan Kochan 02384771cb refactor(store-dir): share the store-index open and drain helpers (#13647)
Ten call sites repeated the same store-index setup and teardown: the
`frozenStore` branch for opening the shared index, the `spawn_blocking`
wrapper around it, the `spawn` / `spawn_disabled` branch for the writer,
and the writer drain. None of it is install logic.

`pacquet-store-dir` owns those types, so it now owns the helpers:
`StoreIndex::shared_for`, `StoreIndex::open_shared`,
`StoreIndexWriter::spawn_for` and `StoreIndexWriter::drain`. They take
`frozen_store: bool` rather than `&Config`, keeping `store-dir` free of
a dependency on the config crate above it.

Two local wrappers that existed only to hold a copy are removed.

The `warn!` target moves from whichever caller copied the block to
`pacquet::store_index`, naming the subsystem the event is about.

No behaviour change otherwise.
2026-08-04 21:55:13 +02:00
Zoltan Kochan df3a09a05b fix(deps-restorer): shim publicly hoisted workspace-package bins on a headless install (#13644)
Share the materialization plan and the link phase between the two
install paths, in `pacquet-deps-restorer`. The skip set decides what
`node_modules` contains and the link phase decides how it is wired, so
the paths diverging in either is a correctness bug rather than a style
difference. `InstallFrozenLockfile::run` drops 875 -> 485.

Sharing the link phase left one behavioural flag no test pinned, and
tracing it found a real bug. A workspace package matching
`publicHoistPattern` is recorded in the hoist result's
`hoisted_workspace_aliases`, not `publicly_hoisted_aliases_with_bins`,
which is all the post-build top-level bin link reads. Its bin therefore
reached the root `.bin` only via the link phase's `node_modules`
re-walk, which only a non-frozen install performed, so
`--frozen-lockfile` left the shim missing. pnpm's headless path
re-walks, so this was pacquet diverging. Both paths now re-walk, which
fixes the gap and removes the flag.

That surfaced a second latent bug: `link_bins::read_package` treated
anything but a missing manifest as fatal, so a stray file in
`node_modules` failed the install with `NotADirectory`. Non-directory
entries now mean "not a package".

Seven genuine differences stay as caller-side inputs, including the
frozen path's non-empty-snapshots guard, which is load-bearing because
`any_installability_constraint` short-circuits on `packages` before
reading snapshots.
2026-08-04 21:09:33 +02:00
Zoltan Kochan 74bf5dd8fd refactor(package-manager): decompose install modules (#13640)
Split package-manager's install implementation into focused internal modules for orchestration, lockfile freshness, modules state, lifecycle execution, and workspace state. Decompose the install coordinator into typed phases, including ownership-specific post-materialization steps for state selection, project linking, modules-state commit, project lifecycle execution, workspace-state update, and reporting. This reduces the reading cost of the former 4,583-line install.rs and roughly 1,900-line run_inner while preserving its public API and behavior.

Move patch-commit's package filtering and diff generation into the existing pacquet-patching crate. Keep patch extraction in package-manager because it depends on install-engine fetching and materialization concerns.

Prevent unsafe modules cleanup from purging the project root in both installers. Write blocked-build approval scaffolding to the discovered workspace manifest when the Rust installer uses per-project lockfiles.
2026-08-04 16:41:38 +02:00
Zoltan Kochan e71978677f refactor(deps-restorer): extract headless install (#13637)
Move the frozen-lockfile installer and its shared dependency preparation primitives into a dedicated internal crate.

Keep the fresh-lockfile path sharing the same linker and build primitives through the new crate, and re-export the restorer API from the package-manager crate to avoid call-site churn. Move the associated unit tests and update test-plan references without changing observable install behavior.
2026-08-04 13:18:03 +02:00
Zoltan Kochan d4a92480e4 fix(package-manager): don't write the PnP loader for a virtual-store-only install (#13636)
`virtualStoreOnly` populates the virtual store and creates no importer
links — it is how `pnpm fetch` warms a store in a Docker layer without
touching the project. The importer symlinks and
`node_modules/.package-map.json` were already skipped on that path, but
`.pnp.cjs` was not.

`.pnp.cjs` is how a PnP project resolves, which makes it a project-level
artifact of exactly the same kind: writing it leaves the project
claiming to resolve out of a store it was never linked into. Gate it on
`virtualStoreOnly` like the other two.

Fixed in all four implementations, since every one carried the gap:
`deps-installer` and `deps-restorer` on the TypeScript side, and the
fresh and frozen paths in pacquet. The headless path reuses the
`skipPostImportLinking` flag it already derives.

Forbidding the combination instead was considered and rejected: `pnpm
fetch` sets `virtualStoreOnly`, so erroring would stop PnP users warming
a store at all, which is a legitimate thing to want.

Two regression tests, each verified to fail when its own guard is
removed:

- `fetch_under_pnp_does_not_write_the_loader` covers the frozen path
  (`fetch` pins `frozenLockfile`, so it always goes headless).
- `virtual_store_only_install_under_pnp_does_not_write_the_loader`
  covers the fresh path, reachable because `virtualStoreOnly` is a
  `pnpm-workspace.yaml` setting a plain install can carry.
2026-08-04 13:12:48 +02:00
Zoltan Kochanandgithub-actions[bot] e3638c1b02 chore: update dependencies, Node.js, pnpm, and GitHub Actions (#13631)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-04 12:07:01 +02:00
Zoltan Kochan 3fb4ea7e36 refactor(package-manager): split InstallWithFreshLockfile::run into phase modules (#13624)
`InstallWithFreshLockfile::run` had grown to 2103 lines — 68% of its
file — and read as one flat sequence with no boundary between the
install phases it drives. Give the fresh-install path one module per
phase, leaving `run` as the orchestrator that threads state between
them:

- `resolver_setup` — registries, store-index handles, and the resolver
  chain with its prefetching and observing wrappers.
- `manifest_transforms` — pnpm's built-in read-package chain
  (`packageExtensions` + `pnpm.overrides`).
- `resolve` — the seeds, options, hooks and reuse decisions the walk
  consumes, the walk itself, and the diagnostics that read its result.
- `materialization_plan` — installability host, engine name, skip set.
- `linking` — prune, the hoisted and isolated linker arms, and the
  module-resolution sidecars.

Two phases stay in the orchestrator on purpose. The build phase is
already `install_frozen_lockfile::run_build_phase`, shared with the
frozen path, so a local wrapper would add a layer and no meaning; the
`CreateVirtualStore` and build invocations are single calls whose option
literals gain nothing from being relocated behind another input type.

The resolver-chain split needs two functions rather than one:
`PrefetchContext` borrows the store handles, so the caller has to own
them before the chain that borrows them is built.

Four duplications fell out along the way:

- The store-index writer drain appeared verbatim on both the
  lockfile-only and materializing paths.
- The build-then-splice pair of `build_fresh_lockfile` and
  `merge_filtered_wanted_lockfile` appeared twice; both call sites are
  now one `build_lockfile` call.
- The fast-override pre-pass and the per-importer resolve closure built
  near-identical `ResolveOptions` literals differing only in
  `project_dir` and the preferred-versions seed, now
  `SharedResolveOptions::build`.
- `HoistedArmInputs` and `IsolatedArmInputs` were strict subsets of
  `LinkPhaseInputs`.

Two behavior-preserving simplifications: `needs_installability_check`
was redundant with `installability_host.is_some()` and collapsed into
it, and the `if x.is_empty() { None } else { Some(..) }` shapes around
the overrides hooks became `.filter(..).map(..)`.

Prose the extraction made redundant is gone: call sites no longer repeat
their callee's documentation. That also surfaced a stale comment
claiming the lockfile maps are built "only when GVS is on", which the
unconditional `build_lockfile` contradicts.

No user-visible change, so nothing to mirror in the TypeScript CLI and
no changeset.
2026-08-04 11:55:36 +02:00
KhảiandClaude 46d7d3eb7d test(cli): pass the restart log path through process.argv (#13621)
The `node -e` helper in the restart tests interpolated the log file's
path straight into a single-quoted JavaScript string literal. A Windows
temp path such as `C:\Users\...\log.txt` is then parsed as JS text, so
`\U` and `\n` become escape sequences and the script appends to a
mangled path instead of the log the test reads back.

Pass the path as a script argument and read it from `process.argv[1]`
instead, so it never reaches the JavaScript parser. The argument that
`pnpm restart` forwards to each script follows it at `process.argv[2]`.

Both copies of the helper are updated: the unit tests in
`cli_args/restart/tests.rs` and the integration tests in
`tests/restart.rs`.

The sibling shell helpers in the same files interpolate the path into a
double-quoted POSIX shell word, which does not eat backslashes, and
those tests are gated to Unix; they need no equivalent change.

Resolves https://github.com/pnpm/pnpm/issues/12720.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-04 01:42:43 +02:00
Zoltan Kochan ee3bb4b586 fix(lockfile): read a CRLF or BOM-prefixed multi-document lockfile (#13609)
`extract_main_document` / `extract_env_document` split the combined
`pnpm-lock.yaml` on literal `---\n` and `\n---\n`. A lockfile pacquet
did not write may carry CRLF line endings — a `core.autocrlf` checkout
on Windows — or a UTF-8 BOM, and neither marker then matches. The main
extractor fell through to returning the whole file, which serde rejected
as `multiple YAML documents detected`, so the lockfile was discarded and
every dependency re-resolved from the registry.

Normalize the whole file (strip BOM, CRLF to LF) inside both extractors,
which covers every caller. `save_value_to_path` already normalized the
same way inline and now shares the helper.

The TypeScript stack needs no counterpart change:
`pnpm11/lockfile/fs/src/yamlDocuments.ts` already normalizes CRLF in
`extractMainDocument` / `extractEnvDocument` and strips the BOM at each
read site.

Closes https://github.com/pnpm/pnpm/issues/13606
2026-08-03 22:18:22 +02:00
Zoltan Kochanandgithub-actions[bot] a93748005a chore: update dependencies, Node.js, pnpm, and GitHub Actions (#13577)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-03 16:45:01 +02:00
Zoltan Kochanandgithub-actions[bot] ebc48abdc5 chore(release): 11.20.0, pacquet 12.0.0-beta.4 (#13608)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
v12.0.0-beta.4 v11.20.0
2026-08-03 15:48:54 +02:00
Khải 37b387f4ed fix(pacquet/login): honor the scope setting (#13255)
pacquet's pnpm login / pnpm adduser honored scope only as the --scope
command-line flag, ignoring it when configured. The TypeScript pnpm CLI
resolves scope through the config chain, so pacquet was missing the
pnpm-workspace.yaml, global config.yaml, and PNPM_CONFIG_SCOPE sources.

Add a scope field to Config, populated from pnpm-workspace.yaml and the
global config.yaml (via WorkspaceSettings) and from PNPM_CONFIG_SCOPE (via
from_pnpm_config_env). The login CLI adapter falls back to the resolved
config.scope when --scope is absent, with the flag still taking
precedence.

.npmrc is deliberately excluded: pnpm's readAndFilterNpmrc keeps only
auth/registry keys (isNpmrcReadableKey), and scope is in neither
NPM_AUTH_SETTINGS nor NETWORK_INI_KEYS, so pnpm drops a `scope=` line
there. A test pins that pacquet ignores it too.

Fixes https://github.com/pnpm/pnpm/issues/12893
2026-08-03 15:46:11 +02:00
Zoltan Kochan 290bb5bd38 ci: create release tags by hand, signed (#13607)
Version tags were created in CI with `git tag "$tag" "$SHA"` — a lightweight tag,
which has no tag object to carry a signature. Since v11.18.0 that made
`git verify-tag` fail with "cannot verify a non-tag object of type commit",
breaking downstream packagers who verify the tag before building.

Signing those tags in CI would restore verify-tag but not the property that makes
it worth verifying: the key would live in Actions secrets, inside the same trust
boundary the signature is meant to attest independently of. Cut tags from a
maintainer machine instead, and have release.yml verify the tag before any
publish job runs.

verify-release-tag imports the public keys committed under .github/release-keys
into a throwaway GNUPGHOME and runs `git verify-tag` on the pushed tag. Every
publish path descends from `plan` and `plan` descends from that job, so one edge
gates all three products. Verified against a signed tag, a lightweight tag, an
annotated unsigned tag, and a tag signed by a key outside the directory: only the
first proceeds.

The keyring is isolated so verification cannot succeed against a key already on
the runner, and checkout sets fetch-tags because a tag push checks out the commit
while the signature lives on the tag object. The non-tag verify_ts_release
dispatch exits from inside the step rather than a job-level `if`, since a skipped
job skips everything that needs it.

This guards against unsigned tags, not against an actor who can push arbitrary
ones: a tag push runs the workflow file and checks out the tree from the pushed
tag. Restricting creation of v* and pnpr@* tags is a repository ruleset concern.

RELEASING.md documents the manual procedure: tag an explicit commit, verify every
tag before pushing, and retag at the fixed commit to retry a failed release.

Related to pnpm/pnpm#13578
2026-08-03 15:43:58 +02:00
Zoltan Kochan 88728f1301 fix(config): treat empty proxy values as unset, matching the TypeScript CLI (#13597)
Treat empty proxy values as unset.

Every source that can configure a proxy (`.npmrc` keys, env vars, CLI
flags, workspace YAML) can produce an empty string; previously only
`parse_proxy_url` rejected it, surfacing as ERR_PNPM_INVALID_PROXY on
every install.

Filtering empty values only where the resolved ProxyConfig is parsed
into the HTTP client (`for_installs_with_redirect`) stops the abort but
leaves an empty value shadowing every lower-priority source. Empty
proxy settings are now dropped as the .npmrc, pnpm-workspace.yaml, and
CLI layers are applied, with the filter at the client-build site kept as
the final normalization. An empty env var keeps shadowing the env vars
below it, and what is left resolves to no proxy.

The legacy `.npmrc` `proxy=` key was the one setting where the two
config-reader paths disagreed: the trusted packageManagerNetworkConfig
path ran it through `getProxyValue` and fell through on empty, while the
main path kept `''` and let a project .npmrc suppress an
environment-configured proxy. Both paths now use `getProxyValue`, and
pacquet's cascade mirrors it.

Also fix the workspace-yaml `noProxy` / `noproxy` aliases: an empty
primary key consumed the alias's turn before being filtered, discarding
a valid bypass list.

Supersedes the branch of pnpm/pnpm#13582, which had maintainer edits
disabled.

Fixes pnpm/pnpm#13533.
2026-08-03 13:36:34 +02:00
Zoltan Kochan 51b43ed396 fix(env-installer): stop pinning the exe package from pnpm v12 (#13599)
From v12 the unscoped `pnpm` package is itself the native executable, so
`@pnpm/exe` is no longer published alongside it. The env lockfile now pins
only `pnpm` for those versions, in both stacks, instead of resolving a
package that does not exist.

The native-binary leg of the engine identity check followed `@pnpm/exe`
by name, so it silently verified nothing once that package is absent. It
now follows whichever package carries the host's platform binary as an
optional dependency: `@pnpm/exe` for the majors that publish it, the
unscoped `pnpm` from v12.

On the TypeScript side the wanted version can arrive as a range or a
dist-tag (`pnpm with latest`), so `pnpm` is resolved first and the second
resolution only happens for the versions that do ship `@pnpm/exe`.
2026-08-03 10:23:06 +02:00
Zoltan Kochan bc57f40240 fix(security): contain the rebuild pkg root against a traversal depPath name (#13596)
GHSA-c59q-g84q-2gj5 reported that the isolated linker joined the
lockfile-derived package name onto the virtual store without validation.
That site, and the PnP `packageLocation`, were already contained by
51300fd41c; the one recommended site that commit missed is the rebuild
phase, which resolves its package root the same way.

`_rebuild` reads the current lockfile from `node_modules` and rebuilds
`pkgRoot` as `path.join(pkgModulesDir(depPath), pkgInfo.name)`, where
`pkgInfo.name` comes from `dp.parse` of the depPath key. `dp.parse` is a
raw substring and validates nothing, so a `../../../escaped@1.0.0` key
pointed the lifecycle-script runner and the post-build bin linking at a
directory outside the virtual store. The dep graph that phase walks comes
from `@pnpm/deps.graph-hasher`, which deliberately defers the name check
to its sink -- and this sink had none.

Route both joins through `safeJoinModulesDir`, so a crafted name fails
with ERR_PNPM_INVALID_DEPENDENCY_NAME before any script runs.

Reachability is narrower than the reported install path: the poisoned
lockfile has to already be in `node_modules`, which means a shipped or
restored `node_modules` rather than a committed `pnpm-lock.yaml`.

No pacquet change: its `rebuild` drives the frozen-install pipeline, which
runs `verify_lockfile_dependency_names` unconditionally over snapshot
package names, and it reads the project lockfile rather than a copy in
`node_modules`, so it has no equivalent unguarded sink.
2026-08-03 02:35:47 +02:00
Zoltan Kochan 641ccd3df4 fix(resolving-deps-resolver): resolve importers' initial waves one at a time (#13594)
Two installs of one workspace, same inputs and same binary, emitted
different lockfiles: a peer suffix would bind `postcss@8.4.19` in one
run and `8.5.25` in the next. https://github.com/pnpm/pnpm/pull/13584
removed most of it — 196 differing lines where there had been 10,390 —
but not the last of it.

Bisecting what varies between runs: the resolved package set and every
package's children are identical, hash for hash. The occurrence nodes
are not, ranging from 58,972 to 60,664 on a 114-importer workspace.
Importers race for a package's children-ownership claim, and a walk
holding the claim only transiently still leaves its occurrence nodes in
the shared tree; how many it leaves depends on the interleaving.
Occurrence identity feeds peer-variant computation, so that is enough
to move a binding.

Resolve the initial waves one importer at a time, boxing each so the
enclosing install future does not grow by a wave's frame. Eight
consecutive replays of that workspace now emit byte-identical
lockfiles, where before almost every pair differed, and the result
matches one of the two lockfiles the concurrent path was alternating
between — no resolution changes, the choice simply stops depending on
scheduling.

It is also faster wherever it was measured, because the races cost
redundant subtree walks: that workspace resolves in ~9.3s against
~10.3s, a cold install over a 50ms link takes 2.64s against 2.72s, and
the resolution fixtures are unchanged. The waves overlapped resolver
waits, but pacquet dedups those through the workspace-wide wanted-dep
cache, so there was less to overlap than the fan-out assumed.

Closes https://github.com/pnpm/pnpm/issues/13567.
2026-08-02 21:33:59 +02:00
Zoltan Kochan 91d3c2e220 refactor(registries): give the known-registry set one owner (#13591)
The built-in aliases and the user's `namedRegistries` were combined
independently at each place that needed them: `resolveNamedRegistries` in
constants, a second call inside the same verifier factory to build the URL
prefix list, and in pacquet a hand-rolled `chain` next to a separate
`build_named_registry_prefixes`. The Rust pair had already drifted — the
hand-rolled merge skipped the URL validation the other applied.

That set feeds two different consumers: alias lookup for an entry that names
its registry in the dep path, and the tarball-URL prefix list that decides
which registry an entry carrying only a recorded URL is verified against.
Splitting the merge across call sites is what made adding the `npmjs`
built-in look local while it silently redirected verification traffic for
lockfiles that record a registry.npmjs.org URL and name no alias at all.
That consequence was caught by an unrelated normalization test that happened
to use such a URL, not by anything at the edit site.

`KnownRegistries` now owns the merge and exposes both views, so a change to
either input reaches both consumers or neither. `createKnownRegistries`
computes the prefixes lazily because alias lookup runs per package while
prefix matching runs only for entries with a recorded URL.

Both stacks now pin the default prefix list in a test, so a new built-in
cannot land without stating where it sends verification traffic.

Writing that test surfaced a latent bug: the two built-in URLs are both 27
characters, and sorting by length alone left their relative order to hash-map
iteration, which varies between runs. The sort now tie-breaks
lexicographically in both stacks. No routing outcome changes — two prefixes
of equal length cannot both match one URL unless they are identical — but the
list is now stable enough to assert on.

Closes https://github.com/pnpm/pnpm/issues/13590
2026-08-02 19:40:34 +02:00
Zoltan Kochan 10481deb9a fix(cli): accept npm's --prefix and --store spellings (#13592)
pnpm 11 rewrites `--prefix` to `--dir` and `--store` to `--store-dir`
before parsing argv (`RENAMED_OPTIONS` in `parseCliArgs.ts`), so npm
habits and existing CI scripts keep working. pacquet had no equivalent
and clap rejected the unknown long flag outright, breaking invocations
like `pnpm --prefix ../ run test:ci`.

Both spellings are added as clap aliases on the global options rather
than as an argv-rewriting pass: the pre-clap passes derive their arity
tables from the clap `Command` and already fold in every alias, so
`--prefix` relocates and steps over its value like `--dir` does. The
aliases are hidden so the help output stays identical to pnpm 11's,
which does not document either spelling.

pnpm additionally drops the alias when a command line spells the same
option both ways, so `--prefix foo --dir bar` and `--dir bar --prefix
foo` both resolve to `bar`. Clap keeps the last occurrence instead, so
`renamed_options` removes the shadowed alias tokens ahead of the parse,
alongside the other pre-clap passes and using the same clap-derived
arity tables.

The `--version` pre-scan reads `--dir` directly to find the project
whose pin decides the delegated version. It now honors `--prefix` there
too, and takes option arity from the clap grammar instead of a
hand-maintained list of value-taking options: the list did not know
about `--store-dir`, so `pnpm --store-dir /s --version` took `/s` for
the command name and stopped scanning.

The clap grammar is built once per process rather than per caller.
Constructing it walks every subcommand, and every pass that runs before
the parse needs the same view of it, so the boundary scan no longer
rebuilds one on each call.

Closes pnpm/pnpm#13583
2026-08-02 18:37:34 +02:00
Zoltan Kochan 21c8ddddbe fix(network): fall back to bundled CA roots when the system trust store is empty (#13593)
reqwest's `rustls-platform-verifier` backend hard-errors with
"No CA certificates were loaded from the system" when the platform
store yields nothing, and the install client turned that into a
`Main thread panicked` through an `.expect` on `ClientBuilder::build`.
Any install on such a machine — a nixpkgs sandbox, a scratch
container — died, offline installs included, even though pnpm 11 works
there because Node ships its own Mozilla root store.

Keep the platform verifier as the primary trust source and fall back to
the Mozilla roots bundled via `webpki-root-certs` only when the client
cannot be built with it at all. A user who deliberately distrusts a root
system-wide keeps that decision as long as their store loads, and a
build failure for any other reason now surfaces as
`ForInstallsError::ClientBuild` — carrying both that error and the
retry's own — instead of a panic.

pacquet-only: the TypeScript CLI is unaffected, since Node's bundled
root store is what makes pnpm 11 work on these systems.

Closes pnpm/pnpm#13588
2026-08-02 17:55:31 +02:00
Zoltan Kochan 54843075f5 perf(resolving-deps-resolver): derive each package's name once for the peer-name cycle graph (#13589)
The cycle graph the final depPath pass consults is over package names,
of which a workspace has orders of magnitude fewer than the occurrences
contributing to it — on the reference workspace, ~5,000 packages against
224,946 occurrences with external peers. It was built by deriving the
name from every one of those occurrences, and `pkg_name_version`
allocates both the name and a version this caller discards, so the graph
cost two allocations per occurrence and its keys and edges cost another
`String` clone each.

Derive the name once per package id and borrow throughout: the graph,
the peer-name list that seeds its isolated nodes, and the Tarjan pass
over it all hold `&str` now. Only the returned cyclic set owns its
names, because it outlives the graph.

Same graph, same cycles. The effect is ~1-2% on the full-size
resolution fixture and below this workspace's run-to-run noise on the
reference workspace; what it removes is a per-occurrence term in a pass
whose result depends only on the package set.

Related to https://github.com/pnpm/pnpm/issues/13505.
2026-08-02 17:34:19 +02:00
Zoltan Kochan 2d30dd6db7 feat(lockfile): key named-registry packages by their registry (#13528)
Packages resolved from a named registry are now keyed
`<name>@<registryName>:<version>` (e.g. `foo@work:1.0.0`) instead of
`<name>@<version>`. Without the qualifier the same name+version served by two
different registries collapsed onto one `packages:` entry, so whichever
resolved first won and the other consumer silently got the wrong tarball.
The lockfile also carried no registry marker at all: entry lookup keys on
`refToRelative(ref, name)`, so a `work:foo` spec could be satisfied by an
entry that was really resolved from npmjs, and vice versa.

The alias goes in the version slot because the dep-path grammar already
admits colon-bearing non-semver values there (`foo@file:...`,
`node@runtime:...`), so every suffix mechanism composes unchanged:
`foo@work:1.0.0(react@18.0.0)` parses with the existing balanced-paren scan,
`depPathToFilename` escapes the colon to `+`, and `refToRelative` already
reconstructs `foo@work:1.0.0` from a `work:1.0.0` snapshot ref with no
change. The alias is recorded rather than the registry URL so keys stay
readable and independent of registry configuration; the concrete URL is
still pinned by the resolution when it is not derivable.

Tarball URLs now apply the pre-existing "omit whatever is reconstructible"
rule against the named registry instead of the scope-routed default, so a
canonical named-registry tarball serializes as bare `{integrity}` and is
recomputed from the alias on read. Registries whose URLs cannot be rebuilt
from name, version, and base URL (GitHub Packages hashed `/download/` URLs)
keep the explicit field, exactly as before.

The lockfile format version is unchanged. Registry-qualified keys are
additive: they appear only for packages resolved through `namedRegistries`,
the tarball-omission rule only fires on those entries, and the alias
validation is config-time, so a project that configures no named registry
produces a byte-identical lockfile. A format bump would not have been
adoptable incrementally — readers gate on the major version alone and reject
an unknown one outright rather than degrading, including pnpm engines
embedded in other tools, which cannot be patched after the fact.

Named-registry aliases that would shadow a reserved specifier prefix
(file, link, workspace, runtime, npm, jsr, and so on) are now rejected at
resolver construction with ERR_PNPM_RESERVED_NAMED_REGISTRY_NAME. They were
previously accepted and silently shadowed by that prefix's own resolver in
the chain, which was harmless while the alias only appeared in specifiers;
in the version slot it would make the dep path genuinely ambiguous.
2026-08-02 16:52:28 +02:00
Zoltan Kochan 7347789a78 perf(resolving-deps-resolver): answer the hoist scope's ancestor queries per shared suffix (#13587)
Deciding whether a missing-peer issue is covered by another importer's
walk scans the issue's ancestor chain for a package that importer owns.
The scan ran per issue, and issues of one peer come from occurrences
spread through the tree whose chains share long suffixes, so the same
suffix was rescanned once per issue: O(issues x depth) per importer,
with the issue count growing with the workspace.

SharedChain already shares those suffixes structurally, so the answer
for a link covers that link and everything above it, for every chain
built on it. `any_memoized` records that answer per link, taking the
total to the number of distinct links; a suffix that already matched
spares each branch's own tip as well.

The post-round scope filter memoizes, per peer name; the walk's own
suppression check does not, because a memo outliving its links cannot
be keyed on their addresses and the walk drops chains throughout. Each
entry holds an `Arc` to the link it is keyed on, so an address cannot
be recycled underneath it.

Output is unchanged. On the full-size workspace resolution fixture,
whose chains are 250 deep, resolution goes from ~1.56s to ~1.19s, and
the CI-sized fixture moves 3%. It is neutral on the 114-importer
bit.cloud workspace, whose chains are too shallow for the rescan to
dominate: the term this removes scales with the issue count, but only
in proportion to chain depth.

Related to https://github.com/pnpm/pnpm/issues/13505.
2026-08-02 15:42:36 +02:00
Zoltan Kochan dbe2dad610 feat(pacquet): ship node-gyp beside the native binary (#13586)
pnpm v12 shipped no node-gyp, so any package that shells out to it during
an install script died with `spawn node-gyp ENOENT`: `node-gyp-build` with
no matching prebuild, `node-pre-gyp`, a plain `"install": "node-gyp
rebuild"`, and the `node-gyp rebuild` script pnpm synthesizes for a package
that ships a `binding.gyp` without one. Embedders worked around it by
putting their own node-gyp on the lifecycle PATH.

pnpm 11 solves this without a JS bundle: node-gyp is declared `external`,
and `pnpm deploy` resolves its whole tree from this repo's lockfile at
release time into a `dist/node_modules/node-gyp` directory that sits beside
the entry point, with `dist/node-gyp-bin` holding the wrappers that go on a
lifecycle script's PATH. Those are ordinary files next to the executable,
so the native binary can ship them the same way — nothing about the
mechanism depended on there being a bundle to hide node-gyp inside.

Resolving the tree at release time rather than at install time is the point,
not an implementation detail: the dependency tree is frozen and reviewed per
pnpm release instead of being fetched from the registry at the moment an
install script is about to execute arbitrary code.

scripts/bundle-node-gyp.mjs builds the payload, reusing the TypeScript CLI's
wrapper scripts so the two stacks cannot drift on how a script's node-gyp is
dispatched, and refusing to produce a partial dist/ — a PATH entry that
resolves nothing is worse than shipping none at all. Both wrappers ship it,
since each places the binary next to itself.

node-gyp moves to a catalog entry so both stacks pin one version; the
Node.js-floor hold-back note moves with it.

At runtime bundled_node_gyp_bin finds the wrapper dir relative to the
executable and returns None when nothing was shipped, which is what a
checkout build sees — lifecycle scripts then fall back to whatever node-gyp
the environment provides, exactly as before. The wrapper itself honors
npm_config_node_gyp and only falls back to the shipped copy when it is
unset, so a user-supplied node-gyp, a workspace one, and a package's own
dependency all keep winning.

Refs https://github.com/pnpm/pnpm/issues/13580
2026-08-02 15:37:41 +02:00
Zoltan Kochan 99194c8145 fix: stabilize pacquet dependency traversal order (#13584)
Canonicalize the child specs extracted from resolved manifests before the
dependency walk. Manifest fetches run concurrently and equivalent JSON maps
can arrive with different insertion orders. Letting that order reach lazy
child discovery changes internal node-ID assignment, which can alter the peer
providers selected later in large workspaces.

Add a regression test proving that reversed dependency property order yields
the same child sequence. Also correct an existing changeset wording issue
found by the repository pre-push spellcheck.

Closes pnpm/pnpm#13567.
2026-08-02 14:48:02 +02:00
Zoltan Kochan c2b2f1578a perf(resolving-deps-resolver): amortize the per-importer workspace scans in the hoist loop (#13579)
Two per-importer rescans of the whole workspace came out of the
peer-hoist loop, which made a hoist wave cost the square of the
workspace rather than the workspace.

Every round of every importer cloned `first_importer_by_pkg` and
`first_walk_missing_by_pkg` whole to build its `HoistMissingScope`.
Each map now carries the `Arc` snapshot last projected from it and
hands that out until a write invalidates it; the two writers that
usually change nothing — an ownership claim re-recording the importer
that already owns the package, and a later round re-recording a
missing-peer report the same owner generation already wrote — test
before writing, so a round that changes nothing reuses the projection.
Snapshots already issued keep what they were built from.

Each discovery pass also folded its subtree summaries into an owned
`pkg id -> missing peer names` map, cloning every descendant's package
id and name set into it, for one consumer that read the map and
dropped it: on the benchmark below, 1.6M entries built and 1.1s spent
freeing them. The summaries are already shared bottom-up by `Arc`, so
a pass now reports its roots and the reader indexes them in place. The
index borrows the names, which makes it free to drop, and the union
several occurrences of one package require is materialized only when a
second summary reports that package.

The hoist-heavy `workspace_full_resolution` scenario (331 importers,
5,030 packages) drops from ~6.7s to ~1.50s; the two scenarios that
never enter the hoist loop are unchanged. On the 114-importer
bit.cloud workspace from the issue the gain is ~0.2s of a 10.4s
resolve, since its importers mostly have their peers provided and its
per-round maps are small.

Related to https://github.com/pnpm/pnpm/issues/13505.
2026-08-02 13:32:40 +02:00
Zoltan Kochan be932ae75e test(micro-benchmark): gate multi-importer workspace resolution in CI (#13581)
The hoist-heavy resolution fixture is the only benchmark that measures
the peer-hoist loop — the importer barrier, the per-round context syncs,
and the per-importer work that scales with the workspace. Nothing ran it
in CI: it lived as a harness-free bench in the resolver crate, and the
scenarios the integrated benchmark does run converge in one hoist round,
so none of them moves when that loop's cost changes. An A/B of the
40-importer integrated-benchmark fixture measures 820.6ms against
819.0ms across a change that makes the loop 4.4x faster.

Move the fixture into the micro-benchmark task, which already builds
both sides of a pull request and posts their comparison, and give it two
sizes. The CI size — 120 importers over 1,200 packages — runs under
criterion on every PR at ~0.2s a resolve; the full size, which models the
331-importer workspace the original regression came from, keeps its
single timed run behind `--full-workspace-resolution`, since a regressed
run there can take minutes and several GiB and has no business in a
statistical harness.

Only the hoist-heavy shape is benched. The plain and peer-heavy shapes
re-measure the tree walk that other scenarios already cover, and with
every peer provided up front their hoist loop converges in one round.

The CI size keeps the signal: across the same change, the group moves
577ms -> 219ms (-62%, p = 0.00).

Related to https://github.com/pnpm/pnpm/issues/13505.
2026-08-02 13:19:57 +02:00