Commit Graph

11624 Commits

Author SHA1 Message Date
Zoltan Kochan
a662de44dd fix: pnpm runtime set defaults to devEngines (#11951)
* fix: pnpm runtime set defaults to devEngines

Previously `pnpm runtime set <name> <version>` wrote to `engines.runtime`
because it ran `pnpm add` with the default `--save-prod`. Default to
`--save-dev` so the runtime lands in `devEngines.runtime`; pass
`--save-prod` (or `-P`) to opt back into `engines.runtime`.

Closes #11948

* fix: honor --save-dev precedence in pnpm runtime set

When both `--save-dev` and `--save-prod` are passed, prefer `--save-dev`
to match `getSaveType`'s precedence elsewhere in pnpm. Also makes the
explicit `--save-dev` flag actually consulted instead of relying solely
on the default branch.

* ci: trigger
2026-05-26 16:04:10 +02:00
Zoltan Kochan
26a7d633bf fix(patching/apply-patch): reject patch paths that escape the patched directory (#11952)
* fix(patching/apply-patch): reject patch paths that escape the patched directory

A malicious .patch file with `diff --git a/../../X` headers could otherwise
write, delete, or rename files outside the patched package as the user
running `pnpm install`.

* refactor(patching/apply-patch): narrow caught errors via util.types.isNativeError

Drops the `any`-typed catch + eslint-disable in favor of the cross-realm-safe
narrowing pattern documented in CLAUDE.md.

* refactor(patching/apply-patch): replace error helper with PatchPathEscapesError class

* chore(patching/apply-patch): reword comment to satisfy cspell
2026-05-26 12:50:19 +02:00
Puneet Dixit
35d235542e fix: validate devEngines runtime onFail (#11822)
Fixes #11818

## Summary

`devEngines.runtime` / `engines.runtime` entries with `onFail: error` or `warn` silently did nothing — only `onFail: download` had any effect. This PR wires up validation for all three supported runtimes (node, deno, bun).

- Add `getSystemDenoVersion` / `getSystemBunVersion` and a generic `getSystemRuntimeVersion(name)` dispatcher in the runtime-version helper package.
- Walk each runtime entry in the manifest during pnpm startup, compare to the live system runtime, and throw `ERR_PNPM_BAD_RUNTIME_VERSION` (or warn) on a mismatch. Invalid ranges (e.g. `"invalid range"`) are reported instead of crashing `semver.minVersion`. Missing runtimes ("no Node.js on the system") get the same error path.
- The shell-out for deno/bun only runs when the manifest configures them AND `onFail` is `error`/`warn`. `download`/`ignore` short-circuit, and projects with no runtime pin pay nothing. Memoized per runtime.
- `pnpm --version`, `pnpm --help`, and `pnpm <cmd> --global` are exempt from the check.
- Rename `@pnpm/engine.runtime.system-node-version` → `@pnpm/engine.runtime.system-version` to match its broader scope; hoist `RuntimeName` / `RUNTIME_NAMES` / `isRuntimeAlias` to `@pnpm/types` so callers don't need to depend on `pkg-manifest.utils` just for the alias check.

## Tests

- `pnpm --filter pnpm run compile`
- `pnpm --filter pnpm exec jest packageManagerCheck.test` — 42 passing. New coverage: node/deno/bun version mismatch, invalid range, missing range, multi-entry runtime arrays, `engines.runtime` path (not just `devEngines.runtime`), and the `pnpm --version` exemption.
- `pnpm --filter @pnpm/engine.runtime.system-version test` — 10 passing, 100% statement coverage; unit tests for each helper and the dispatcher.
- Manual end-to-end smoke tests against the rebuilt bundle for deno and bun version mismatch.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added runtime version validation for Node.js, Deno, and Bun. The system now enforces `devEngines.runtime` and `engines.runtime` declarations with configurable failure behavior (`error`, `warn`, or `ignore`).
  * Enhanced error messages for runtime version mismatches with helpful suggestions for overrides.

* **Improvements**
  * Improved system runtime detection and version checking across multiple runtime environments.

---------

Co-authored-by: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-26 10:29:40 +02:00
Zoltan Kochan
a1f6f32996 fix(pacquet/package-manager): build workspace state from project list, not lockfile (#11946)
`build_projects_map` derived workspace projects from `lockfile.importers.keys()`. On the fresh-install path the wanted lockfile is `None` (no `pnpm-lock.yaml` on disk yet), so the function fell into its no-lockfile arm and recorded **only the root importer** — even for an 87-project workspace like babylon.

That broke the [`optimistic_repeat_install`](https://github.com/pnpm/pnpm/blob/6b3ba4d337/pacquet/crates/package-manager/src/optimistic_repeat_install.rs) fast path on the *next* install: `project_structure_matches` compared the recorded project list (`len = 1`) against the workspace projects the resolver discovered (`len = 87`), returned `false`, and the install fell through to the full resolve + verify path even though the on-disk state was already a no-op.

## Fix

Upstream pnpm's [`createWorkspaceState`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/workspace/state/src/createWorkspaceState.ts#L14-L27) takes `allProjects: ProjectsList` as input and emits one entry per project — the lockfile isn't involved. Pacquet already builds the matching `project_manifests: &[(PathBuf, &PackageManifest)]` at the top of `Install::run` (for the optimistic-repeat fast path); thread it through to `build_workspace_state` instead of re-deriving from the lockfile.

The new `build_projects_map` is half the size of the old one and can't fall into a "lockfile missing" arm — the project list always comes from the same scan the rest of the install uses.

## Impact

Re-bench against the vlt `babylon` fixture (87-project monorepo) shows every `*+node_modules` cell collapsing from "very slow" to "faster than pnpm":

| Variation | Before | After |
|---|---:|---:|
| `node_modules` | 37.87× | **0.09×** (11× faster than pnpm) |
| `lockfile+node_modules` | 25.52× | **0.07×** (14× faster) |
| `cache+node_modules` | 11.55× | **0.15×** (6.7× faster) |
| `cache+lockfile+node_modules` | 11.35× | **0.17×** (5.9× faster) |

These were the four worst cells in the vlt benchmark chart for babylon (all flagged DNF before #11944 fixed the underlying panic). After #11944 babylon stopped DNF'ing but stayed 11-37× slower because of this workspace-state writing bug.

The fast path only failed for workspace installs whose wanted lockfile was absent on the first install of the iteration — i.e. exactly the vlt benchmark's `node_modules` and `*+node_modules` shape. Single-project installs always recorded the root correctly because the no-lockfile fallback already emitted the root entry.

Found while validating #11944's claim that the babylon DNF cells would collapse on the next pacquet release (#11902).
2026-05-26 02:24:13 +02:00
Zoltan Kochan
198c661b99 fix(pacquet): require pnpm-lock.yaml for single-project optimisticRepeatInstall fast path (#11945)
* fix(pacquet): require pnpm-lock.yaml for single-project optimisticRepeatInstall fast path

The port of pnpm's `optimisticRepeatInstall` short-circuit in #11943
applied the workspace branch's mtime-only exit
(`checkDepsStatus.ts:263-271`) to every install, including single-project
ones. Pnpm's single-project branch (`checkDepsStatus.ts:387-462`)
additionally throws `RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND` when
`pnpm-lock.yaml` is absent, which the outer `try` converts into
`upToDate: false`. Without that gate, pacquet treated a single-project
install with `node_modules` present but no lockfile as "Already up to
date" — the pnpm.io `node_modules`-only and `cache+node_modules`
benchmark cells finished in ~35 ms instead of running the install
(pnpm ~5–7 s on the same fixtures).

Add an `is_workspace_install: bool` parameter; in single-project mode,
require `<workspace_root>/pnpm-lock.yaml` to exist before declaring the
install up to date. Workspace installs continue to skip the lockfile
probe — pnpm's workspace branch's only lockfile check
(`findConflictedLockfileDir`) silently `continue`s on ENOENT
(`checkDepsStatus.ts:593-596`).

Tests:
- `returns_skipped_when_lockfile_missing_in_single_project_mode`
- `returns_up_to_date_in_workspace_mode_without_lockfile`
- `optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing`
  (install-level integration test)
- Existing happy-path tests now seed `pnpm-lock.yaml` via a new
  `write_empty_lockfile` helper in `setup_fresh_install`.

* test(pacquet): prove single-project optimisticRepeatInstall round-trips end-to-end

Add `optimistic_repeat_install_round_trips_on_single_project_install`:
two real `Install::run` calls back-to-back on a non-workspace project
(no `pnpm-workspace.yaml`). The first install resolves through the
registry mock and writes `pnpm-lock.yaml` + `.pnpm-workspace-state-v1.json`
to disk. The second install must hit the optimistic fast path — emit
`Already up to date` and skip every install-setup event. Pairs with the
negative `..._does_not_short_circuit_when_lockfile_missing` test so the
gate's polarity is pinned in both directions.

* style(pacquet): cargo fmt
2026-05-26 01:43:21 +02:00
Zoltan Kochan
6b3ba4d337 fix(pacquet): port pnpm's workspace-link short-circuit and depPath helpers (#11944)
`pacquet install` was panicking on the babylon vlt fixture under the `node_modules` and `lockfile+node_modules` variations. The crash surfaced in [`PkgNameVerPeer::without_peer`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/pacquet/crates/lockfile/src/pkg_name_ver_peer.rs#L55-L62), but the root cause was an unported piece of pnpm's resolver: workspace `link:` nodes weren't short-circuited and flowed through peer resolution, producing depPaths of the shape `link:<rel-path>(<peers>)` that downstream code wasn't prepared for. This PR ports the missing short-circuit, fixes the panic, and closes the parity gaps the audit surfaced.

### Workspace-link short-circuit (the root fix)

Ported upstream's [`isLinkedDependency`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/installing/deps-resolver/src/resolveDependencies.ts#L926-L937) arm plus the [`depth === -1`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/installing/deps-resolver/src/resolvePeers.ts#L396) short-circuit in `resolvePeers.ts`. Workspace `link:` deps now:

- Skip child recursion in the tree walker (`TreeChildren::Realized(empty)`).
- Carry `depth = -1` on the tree node.
- Carry empty `peer_dependencies` on the `ResolvedPackage` — peer matching is the linked importer's concern.
- Use a leaf [`NodeId`] so every reference to the same workspace path shares one id.

`resolve_peers::resolve_node` early-returns for `depth == -1` nodes with `dep_path = pkg.id` (just `link:<rel-path>`). The link node never enters the graph, so `packages:` / `snapshots:` stay clean and the importer's `version:` cell carries `link:<rel-path>` exactly.

### `PkgNameVerPeer::without_peer` no longer panics

Construct the bare key through the typed fields rather than reformatting `{prefix}{version}` and re-parsing under `.expect(...)`. The new `PkgVerPeer::without_peer` clones the existing `prefix`/`version` slots and returns a `PkgVerPeer` with an empty peer string. No round-trip, no `.expect(...)`. Defensive even after the upstream fix lands — `without_peer` is called on values from the lockfile, which can be hand-edited.

### `pkgIdWithPatchHash` is now strip-peer-only at every site

Four call sites built a [`PkgIdWithPatchHash`] from the wrong baseline:

- `virtual_store_layout::lockfile_to_dep_graph` stripped both segments (`PkgNameVerPeer::without_peer().to_string()`) — patched variants of the same `name@version` collided in the dep-graph hash input.
- `create_virtual_store`'s two `cas_paths_by_pkg_id` inserts and `hoisted_dep_graph`'s `pkg_id_with_patch_hash` initialiser stripped nothing (raw `snapshot_key.to_string()`) — peer variants of the same patched package got separate CAS-paths entries instead of sharing one.

All four now go through `pacquet_deps_path::get_pkg_id_with_patch_hash` (the balanced-paren scan upstream's [`getPkgIdWithPatchHash`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/path/src/index.ts#L63-L70) uses): strip the peer-graph suffix, keep `(patch_hash=…)`. Non-patched packages are unaffected.

### New helpers in `pacquet-deps-path`

- `is_runtime_dep_path` — matches `^(?:node|bun|deno)@runtime:` byte-level. Pnpm filters the runtime-only install pass with this.
- `try_get_package_id` — strips peer-graph + patch-hash suffix, then drops the `<name>@` prefix on URL-shaped resolution ids while keeping `runtime:` entries intact.

### Ported `deps/path/test/index.ts` cases

| Suite | Cases ported |
|---|---|
| `pacquet-deps-path::suffix_index` | runtime, scoped, scoped + patch-hash, scoped + peer, scoped + both, leading-slash-legacy nested-peer, scope-with-parens |
| `pacquet-deps-path::is_runtime_dep_path` | pnpm's `isRuntimeDepPath` test + carve-outs |
| `pacquet-deps-path::try_get_package_id` | pnpm's `tryGetPackageId` test + URL-shape, bare, runtime carve-outs |
| `pacquet-lockfile::PkgVerPeer` | patch-hash + peer round-trip |
| `pacquet-lockfile::PkgNameVerPeer` | file-protocol tarball, patch-hash + peer, scope-with-parens, babylon regression |
| `pacquet-resolving-deps-resolver::tests` | babylon-shape: workspace dep with peers → `depth = -1`, empty children, no peer_dependencies |
| `pacquet-package-manager::virtual_store_layout::tests` | patched snapshot → `full_pkg_id` retains `(patch_hash=…)` |

Resolves #11939.
2026-05-25 23:45:49 +02:00
Zoltan Kochan
c8c50caeca perf(pacquet): port optimisticRepeatInstall fast path for repeat installs (#11943)
Closes #11940.

## Summary

Ports upstream pnpm's `optimisticRepeatInstall` + [`checkDepsStatus`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts) dispatch ([`installing/commands/src/installDeps.ts:179-194`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/installing/commands/src/installDeps.ts#L179-L194)). When nothing has changed since the previous successful install, `Install::run` now logs `Already up to date` and returns **before**:

- loading the wanted or current lockfile,
- building the lockfile-verifier list,
- the `verify_lockfile_resolutions` fan-out (and the `<cache_dir>/lockfile-verified.jsonl` lookup it performs internally),
- `getContext`, project registration, `validateModules`,
- the no-op short-circuit that fires *after* all of the above.

That's the missing earlier shortcut from the original investigation. It's what lets pnpm finish the vlt `lockfile+node_modules` cells in ~580 ms regardless of `~/.cache/pnpm` state — the verifier-cache file the bench wipes is irrelevant because pnpm never reaches the verifier on a repeat install.

## How it works

The fast path keys off `<workspace_root>/node_modules/.pnpm-workspace-state-v1.json`'s `lastValidatedTimestamp` against each project's `package.json` mtime, plus a settings-drift check and a workspace-project structure check. Wire shape and field-by-field comparison match upstream's [`WorkspaceStateSettings`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/workspace/state/src/types.ts) so a previous-install state file written by pnpm is honored by pacquet and vice versa.

Settings construction is shared between `build_workspace_state` (writer) and `check_optimistic_repeat_install` (reader) via `optimistic_repeat_install::current_settings`, so the two can't drift on a new field.

## Scope

This PR ports the mtime-vs-`lastValidatedTimestamp` exit only — upstream's `modifiedProjects.length === 0` branch at [`checkDepsStatus.ts:263-271`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L263-L271). Branches that detect a modified project and then re-verify the lockfile (`assertWantedLockfileUpToDate`, `patchesOrHooksAreModified`) aren't ported here — when any manifest is newer than the last validation, this function returns `Skipped` and the install falls through to the regular path, which still has its own freshness guards (`check_lockfile_freshness`, the existing no-op short-circuit).

Disabled under `--frozen-lockfile` so a headless install still fails loudly on missing / stale lockfiles, matching upstream not calling `checkDepsStatus` in that mode.

## Config

New `optimistic_repeat_install: bool` field on `pacquet_config::Config`, default `true` — matches [`config/reader/src/index.ts:169`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/config/reader/src/index.ts#L169). Wired through `pnpm-workspace.yaml` via `WorkspaceSettings.optimistic_repeat_install: Option<bool>`. Yaml `optimisticRepeatInstall: false` opts out per-project; the value also lives in the global config file's allowlist so users can opt out at the user level.
2026-05-25 23:38:00 +02:00
Zoltan Kochan
cc4ff817aa fix(pacquet/registry): deserialize optionalDependencies and peerDependenciesMeta (#11934)
`PackageVersion` (the per-version registry manifest the npm resolver parses) was missing the `optionalDependencies` and `peerDependenciesMeta` fields. The resolver builds `ResolveResult.manifest` via `serde_json::to_value(picked)` and downstream walks it with [`extract_children`](https://github.com/pnpm/pnpm/blob/1fb8a2d5d8/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs#L752-L759) (reads `optionalDependencies`) and [`extract_peer_dependencies`](https://github.com/pnpm/pnpm/blob/1fb8a2d5d8/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs#L776-L824) (reads `peerDependenciesMeta`). Without those fields on the struct, both reads always saw nothing — so `optionalDependencies` edges were silently dropped, and every optional peer was treated as required, then auto-installed via the `autoInstallPeers` fallback in [`hoist_peers`](https://github.com/pnpm/pnpm/blob/1fb8a2d5d8/pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs#L134-L136).

## Astro cascade

On the vlt [`astro`](https://github.com/vltpkg/benchmarks/tree/main/fixtures/astro) fixture, `unstorage` (a transitive of astro) declares 19 optional peers via `peerDependenciesMeta` (`@azure/*`, `@vercel/*`, `@netlify/blobs`, `@upstash/redis`, `@deno/kv`, `ioredis`, `uploadthing`, …). Pacquet's resolver auto-installed every one of them and walked their transitive trees; astro's own `optionalDependencies` (`sharp`) went missing entirely.

The supposed "5.5× astro deep-tree slowdown" tracked in #11902 was almost all wasted work, not a real perf bug. None of the candidate hypotheses listed there (`async_recursion` Box-pinning, per-node `lock_recoverable` mutex acquires, manifest `serde_json::to_value` cost, tarball extraction) were the bottleneck.

## Before / after on vlt astro

| Metric | Before | After | pnpm 11.3.0 |
|---|---:|---:|---:|
| `pacquet install` wall time (warm store) | 39.6 s | 8.5 s | 7.0 s |
| Lockfile lines | 13,364 | 3,037 | 3,444 |
| `resolution:` entries | 1,535 | 377 | 377 |
| Astro root peer suffixes | 30 (`@azure/...`, `@vercel/...`, ...) | `(rollup@4.60.4)(typescript@5.9.3)` | `(rollup@4.60.4)(typescript@5.9.3)` |
| `sharp` (`optionalDependencies`) refs in lockfile | 0 | 110 | 85 |

Warm-cache hyperfine (3 runs, fresh `node_modules` + lockfile each time):

```
pacquet (patched):   670 ms ± 72 ms
pnpm 11.3.0:        1270 ms ±  7 ms
pacquet is 1.89 ± 0.20 times faster than pnpm
```

Closes the astro column in #11902.

## Implementation

- Add `optional_dependencies: Option<HashMap<String, String>>` and `peer_dependencies_meta: Option<HashMap<String, PeerDependencyMeta>>` to `PackageVersion`. The existing `#[serde(rename_all = "camelCase")]` handles wire format.
- Add a `PeerDependencyMeta` newtype with just the `optional` field (the only field the resolver consumes).
- Fix up the four struct-literal construction sites in tests + the trust-evidence projection.
- Add a regression test that deserializes a fixture with both fields populated and asserts they round-trip through `serde_json::to_value` — which is what the resolver consumes.
2026-05-25 18:33:02 +02:00
Zoltan Kochan
7120ac0813 fix(pacquet/resolving-npm-resolver): singleflight verifier lookup caches (#11933)
* fix(pacquet/resolving-npm-resolver): singleflight the verifier lookup caches

Convert PublishedAtLookupContext's five per-key dedup caches from
Mutex<HashMap<String, T>> to Mutex<HashMap<String, Arc<OnceCell<T>>>>
so two verifier tasks that race for the same key share one in-flight
fetch instead of both performing the work.

Mirrors upstream's `Map<string, Promise<T>>` singleflight pattern
(see #11932). The outer mutex is dropped before awaiting the init
future so unrelated keys stay unblocked.

Add a fan-out regression test that asserts 16 concurrent verifications
of the same (registry, name, version) issue exactly one abbreviated
GET; without the singleflight property mockito's `.expect(1)` fails
with 16 requests received.

Refs #11932.

* fix(pacquet/resolving-npm-resolver): rename SingleflightMap generic to satisfy dylint

`perfectionist::single-letter-generic` flagged the `T` parameter on
the new `SingleflightMap` type alias. Rename to `Value` — the slot
contents are the values cached behind the singleflight cells.
2026-05-25 17:42:53 +02:00
Zoltan Kochan
1fb8a2d5d8 perf(pacquet): unlock no-op short-circuit + port abbreviated-modified verifier shortcut (#11931)
Two fixes that together unlock pnpm-parity on the
`benchmarks.vlt.sh` `lockfile+node_modules` shape — the row where
pacquet was 2-12× slower than pnpm on every fixture.

### 1. `fix(modules-yaml)`: normalise joined `virtualStoreDir`

`read_modules_manifest` joins a stored relative `virtualStoreDir`
with `modules_dir` to recover an absolute path, mirroring upstream's
`path.join(modulesDir, modules.virtualStoreDir)`. Node's `path.join`
normalises interior `..` segments; Rust's `PathBuf::join` does not.
Stored values like `../../../Users/.../store/v11/links` came back
as `<modules_dir>/../../../Users/.../links` — never byte-matched
`Config::effective_virtual_store_dir()`, so the no-op short-circuit
added in #11904 silently missed every install whose store sits
outside the project (the default macOS / Linux setup).

The accompanying refactor lifts `lexical_normalize` (already
duplicated in `cmd-shim` and `store-dir`) into `pacquet-fs` so
`modules-yaml` doesn't make it a third copy.

### 2. `perf(resolving-npm-resolver)`: port the missing verifier layers

The npm resolution verifier walked a 4-layer fallback chain in
upstream pnpm (abbreviated-modified shortcut → on-disk full-meta
mirror → npm attestation endpoint → full packument fetch); pacquet
only had the last two. The module's doc-comment explicitly noted
"Phase 4 stubs the abbreviated-shortcut and on-disk-mirror layers
(no cached fetcher / no mirror yet); Phase 5 ports
`fetchFullMetadataCached.ts`…" — this is Phase 5.

Result: a cold lockfile-verification pass now pays at most one
*abbreviated* GET per name (small payload, decided by package-level
`modified`) instead of a full-meta GET per name (hundreds of KB
each).

## Bench

5-iteration cold-cache measured pass on `vltpkg/benchmarks/fixtures/svelte`
(`pnpm-lock.yaml` + `node_modules` present, `~/.cache/pnpm` and
store wiped before each run), 10-core M-series Mac:

|             |  pnpm  | pacquet@main | this PR |
|-------------|------:|-------------:|--------:|
| wall time   | 0.54 s | 2.16 s       | 0.71 s  |

3.0× faster on the `lockfile+node_modules` row.
2026-05-25 17:00:16 +02:00
Zoltan Kochan
b7229f8571 fix(pacquet/resolving-npm-resolver): honor linkWorkspacePackages for bare-semver deps (#11930)
* fix(pacquet/resolving-npm-resolver): honor linkWorkspacePackages for bare-semver deps

Pacquet's npm resolver only consulted the workspace map for
`workspace:`-prefixed wanted deps; bare-semver ranges always went
straight to the registry. When the workspace package isn't on npm
(e.g. babylon's `@dev/build-tools`), the install errored out with
404; when a same-named package existed on the registry, pacquet
silently linked the wrong copy.

Mirror pnpm's three workspace branches around `pickPackage`:

* registry pick succeeded + workspace shadow (exact `name@version`
  match, higher local version, or `preferWorkspacePackages`),
* registry pick returned `null` → workspace fallback,
* registry fetch errored → workspace fallback (swallow workspace
  errors and re-raise the registry error).

Gated by `link-workspace-packages` (true / false / "deep") which is
now parsed from `pnpm-workspace.yaml`, flows through `Config`, and
is encoded into `ResolveOptions::always_try_workspace_packages` at
the install layer. Tri-state semantics are preserved on the config
side; pacquet's single-`base_opts` deps-resolver collapses `true`
and `"deep"` onto the same per-call flag until depth threading
lands.

Closes #11929.

* fix(pacquet/resolving-npm-resolver): swap unicode ellipsis for ascii triple-dot to satisfy Dylint

* test(pacquet/resolving-npm-resolver): port remaining linkWorkspacePackages tests from pnpm

Cover the six pnpm tests the initial port skipped:

* `injected_workspace_match_emits_file_resolution` — workspace
  shadow branch with `injected: true` emits a `file:` resolution.
* `workspace_fallback_picks_highest_version_for_latest_tag` — 404
  fallback into a multi-version workspace via the Tag branch of
  `pick_matching_local_version_or_null`.
* `workspace_fallback_picks_local_prerelease_for_latest_tag` — 404
  fallback into a prerelease-only workspace, exercising
  `resolve_workspace_range`'s `includePrerelease` arm.
* `workspace_fallback_resolves_specific_version_request` — 404
  fallback against a pinned version-spec lookup.
* `workspace_fallback_kicks_in_when_registry_lacks_requested_version`
  — `Ok(None)` fallback path (registry serves the packument but no
  version matches), distinct from the `Err` 404 path.
* `registry_error_propagates_when_workspace_has_no_matching_version`
  — negative test verifying the original 404 surfaces when the
  workspace can't satisfy the request.
* `registry_pick_wins_when_workspace_version_does_not_match` —
  workspace shadow no-op when the workspace carries a different
  version than the registry pick.

* fix(pacquet/resolving-npm-resolver): drop trailing comma in single-line assert! to satisfy Dylint

* test(pacquet/resolving-npm-resolver): trim redundant doc-prose on linkWorkspacePackages tests

Per pacquet/CLAUDE.md "tests are documentation" — the new test
docblocks restated what the test name plus body already say. Keep
only the upstream-link citation (required by the porting rule) and
drop the trailing narrative. Two ports keep one extra sentence
because the distinction they exercise (the `Ok(None)` vs `Err`
fallback split, the `includePrerelease` arm) is not recoverable
from the test body alone. Also drop the inline `lockfile_dir` /
"Registry returns a packument..." comments that narrate setup the
helper code already encodes; keep the `latest`-back-stamp comment
because it explains why a workspace-resolved result still carries
that field.
2026-05-25 16:26:39 +02:00
Nicolas Le Cam
e52e4fce63 feat(pacquet): port detect-libc to Rust and replace ad-hoc libc detection in graph-hasher (#11921)
* refactor(graph-hasher): replace ad-hoc libc detection with pacquet-detect-libc

Extract libc detection into a new `pacquet-detect-libc` crate ported from
the upstream `detect-libc` JS package, replacing the limited ad-hoc
`detect_host_libc()` in graph-hasher.

Detection uses a three-tier fallback (ELF header → filesystem → command)
that avoids spawning processes in the common case and works in slim
containers where getconf or ldd may not be present.

The command step runs getconf and ldd --version as separate subprocesses
to avoid stream pollution between the two, with ldd only invoked when
getconf fails.

* fix(detect-libc): harden ELF parser, UTF-8 decoding, test cfg, and imports

- Use checked arithmetic (checked_add/checked_mul) in elf_interpreter
  to return None on overflow instead of panicking on malformed headers
- Use from_utf8_lossy for /usr/bin/ldd content so non-UTF-8 bytes don't
  skip the filesystem detection path
- Gate detect_integration_host test with #[cfg(target_os = "linux")]
  so it doesn't fail on non-Linux platforms
- Replace use super::* with explicit imports in command tests

* fix(detect-libc): use from_utf8_lossy for command output, fix lints and tests
2026-05-25 15:32:11 +02:00
Nicolas Le Cam
97391bf341 test(pacquet/package-manager): make side-effects write test umask-agnostic (#11922)
* fix(pacquet/package-manager): make side-effects write test umask-agnostic

The test hardcoded mode 0o644 in the pre-seeded PackageFilesIndex row,
but fs::write() assigns mode according to the process umask (e.g., 0o664
with pam_umask usergroups, Debian's default). calculate_diff() compares
both digest and mode, so a mode mismatch caused a false-positive assertion
failure for unchanged index.js on systems with umask 0002.

Read the actual file mode from the written fixture and use it in the
pre-seeded row instead of a hardcoded value.

* fix(pacquet/package-manager): factorize umask-agnostic mode into fixture

Move the actual-mode reading into create_postinstall_modifies_source_fixture
so both write_path_populates_side_effects_row and
write_path_cache_key_includes_patch_hash share it, instead of each test
duplicating the metadata inspection.
2026-05-25 14:44:42 +02:00
Zoltan Kochan
d579e6cbb5 perf(pacquet): trim install-phase syscalls and allocations (#11864)
* perf(fs,package-manager): striped CAS lock + skip pre-flight stat on fresh-target imports

Two install-phase syscall trims:

- `cas_write_lock` swaps the per-path `DashMap<PathBuf, Arc<Mutex<()>>>`
  for 256 static `Mutex<()>` stripes keyed by hashed path. Every CAFS
  write previously paid one `PathBuf::to_path_buf` allocation, a
  `DashMap` shard write lock, plus an `Arc<Mutex<()>>` slot allocation
  even though contention was vanishingly rare. Striping keeps the
  writer/verifier coordination the per-path mutex provided while
  removing those per-call costs. With 256 stripes and ~10 rayon
  workers the false-sharing probability per pair is ~4%, and the
  guarded body (one `O_CREAT|O_EXCL` open + `write_all` of a tar
  entry) is microseconds long.

- `import_indexed_dir::populate_dir` now calls a new
  `import_into_fresh_target` instead of `link_file`. `populate_dir`
  only ever runs against a directory it just `mkdir`'d, so the
  `fs::metadata` pre-flight `link_file` performs to protect the
  Copy-method overwrite contract is wasted — every call is `NotFound`
  in practice and the EEXIST surface from the import syscall is the
  only collision signal we need. Saves ~170k `stat` syscalls per
  clean install on the alotta-files fixture. `link_file` still
  exists with the original semantics for any caller that genuinely
  doesn't know whether the target is fresh.

On the 3343-package alotta-files fixture against the verdaccio mock,
clean-install wall time goes from ~28s to ~19-22s on the local 10-core
machine — roughly closing the gap to pnpm (~20s) for that scenario.

Refs #11857, #11851.

* perf(store-dir): trim per-CAS-file allocations on the hot write path

Two micro-optimisations in `cas_file_path`, the helper every CAFS
write goes through:

- `cas_file_path` no longer `format!`s the sha-512 digest into a
  fresh `String`. Sha-512 is always 64 bytes / 128 hex chars, so
  render the hex into a stack buffer and slice it into the
  `file_path_by_hex_str` call instead. One heap allocation per file
  shaved off — ~170k on the alotta-files clean install.

- The repeated `self.v11().join("files")` rebuild used to walk two
  `PathBuf::join`s per call. Memoise the result behind a `OnceLock`
  on `StoreDir` (`cached_files_dir`) so `file_path_by_head_tail`
  borrows it without re-joining. Race-free initialisation across
  rayon workers, one allocation per process instead of one per file.

Refs #11857.

* docs(pacquet): address CodeRabbit nits

- Refresh `import_indexed_dir` doc comments so they name
  `import_into_fresh_target()` (the actual materialization helper
  after the fresh-target split) instead of `link_file()`.
- Add a const assertion that `NUM_CAS_LOCK_STRIPES` stays a power of
  two, since `cas_write_lock` uses `& (NUM_CAS_LOCK_STRIPES - 1)` as
  the stripe selector.

* docs: forbid past-implementation history in comments

- Extend AGENTS.md Comments rules: comments must describe the
  current contract, not what the code replaced. Phrasings like
  "used to", "previously", "the original X", or parentheticals
  naming a removed type belong in `git log`.
- Apply the rule to `cas_write_lock`'s doc, which previously
  framed itself in terms of the removed
  `DashMap<PathBuf, Arc<Mutex<()>>>` shape.
2026-05-25 14:36:05 +02:00
mehmet turac
440e15586d fix: summarize all global update groups (#11920) 2026-05-25 14:18:58 +02:00
Zoltan Kochan
ae2175829a feat(registry-access): extract dist-tag + adduser helpers, dogfood from tests (#11926)
* feat(registry-access): extract setDistTag and dogfood from tests

Add `@pnpm/registry-access.commands#setDistTag` — the low-level PUT to
`/-/package/:pkg/dist-tags/:tag`. The CLI `dist-tag add` handler now
calls it instead of issuing the fetch inline.

Tests in this monorepo now use a thin new package
`@pnpm/testing.registry-mock` (REGISTRY_MOCK_PORT + REGISTRY_MOCK_CREDENTIALS
baked in) that delegates to `setDistTag`, replacing `addDistTag` from
`@pnpm/registry-mock`. That dropped helper relied on
`anonymous-npm-registry-client` and a verdaccio-era
fetch-then-DELETE-then-PUT dance that is no longer needed against
pnpm-registry.

39 test files swapped from `@pnpm/registry-mock` to
`@pnpm/testing.registry-mock`.

* fix: move setDistTag to its own package to break tsconfig project-reference cycle

testing/registry-mock → registry-access.commands → releasing/commands
→ installing/commands → installing/deps-installer → testing/registry-mock.

Extract setDistTag into @pnpm/registry-access.set-dist-tag (only depends
on @pnpm/error, @pnpm/network.fetch, @pnpm/npm-package-arg). Both
@pnpm/registry-access.commands and @pnpm/testing.registry-mock import
from it. Cycle gone.

* feat(registry-access): extract addUser helper, dogfood from login + tests

Add @pnpm/registry-access.add-user — a small helper that PUTs to
/-/user/org.couchdb.user:<name> and returns { token }. The CLI's
classicLogin (pnpm login fallback path) now calls it, and tests
use it via @pnpm/testing.registry-mock instead of the legacy
addUser from @pnpm/registry-mock.

Swapped 3 call sites: globalSetup.js, installing/deps-installer's
auth.ts, and pnpm/test/dlx.ts. AddUserHttpError exposes status +
text + parsed-json-if-applicable + headers so the CLI can still
do its OTP detection. One webauth-OTP login test mock had to be
adjusted to provide its body via `text` (JSON-stringified) rather
than `json` only, since the helper consumes the body via `text()`.

* refactor: consolidate set-dist-tag + add-user helpers into one @pnpm/registry-access.client package

One shared package is better than splitting per endpoint. Future endpoints
(publish, deprecate, etc.) can land here without another wrapper.

No behavioral change — same setDistTag and addUser exports as before,
just under one roof. Callers updated: registry-access.commands,
auth.commands, testing.registry-mock.

* fix(registry-access): sort imports
2026-05-25 14:01:00 +02:00
Zoltan Kochan
ac299aa0e5 fix(pacquet,package-manager): walk every workspace project in fresh-resolve install (#11905)
* fix(package-manager): walk every workspace project in fresh-resolve install

The fresh-resolve install path (no `--frozen-lockfile`, no usable lockfile)
only resolved the workspace root manifest, so sibling workspace projects'
own dependencies never landed in the lockfile or on disk. Re-run
`resolve_importer` per importer with shared install caches
(`meta_cache`, `fetch_locker`, `picked_manifest_cache`), merge the
per-importer graphs, and emit one `importers[<id>]` entry per project.

Mirrors upstream's [`resolveRootDependencies`](https://github.com/pnpm/pnpm/blob/3422cecfd3/installing/deps-resolver/src/resolveDependencies.ts#L327-L437)
iteration shape — one shared resolution context, per-importer
direct-deps slices.

Per-importer `link_bins` so each project gets its own
`node_modules/.bin`. GVS `register_project` now loops every importer
key the freshly-built lockfile carries, mirroring the frozen path.

`importer_dep_version` and `snapshot_dep_ref` learned a `link:`
short-circuit so workspace-sibling edges emit
`ImporterDepVersion::Link` / `SnapshotDepRef::Link` instead of
falling through to the `name@version` parser.

Cross-importer `TreeCtx` sharing (full upstream parity: one
resolution context with per-importer hoist loops) is deferred — each
`resolve_importer` call still has its own context. Network-side
caches still amortize packument fetches and JSON parsing across
importers; only per-resolve semver matching duplicates.

Closes #11901.

* fix(workspace): drop trailing comma on single-line assert_eq! for Perfectionist lint

* fix(package-manager): register only the workspace root with the store, matching pnpm

Pacquet was looping `register_project` over every importer in both
the frozen-lockfile and fresh-lockfile branches, but upstream pnpm
calls `registerProject(opts.storeDir, opts.lockfileDir)` exactly once
per install against the workspace root — store prune walks the
workspace's `node_modules/.pnpm/` to find every installed package, so
one registry entry per workspace is enough.

Consolidate to a single call near the start of `Install::run`,
matching pnpm's `getContext` ordering at
<https://github.com/pnpm/pnpm/blob/d8a79a9c30/installing/context/src/index.ts#L128>.

Also port two upstream-derived tests that the multi-importer rewrite
of `compute_corrected_optional` and the per-importer link rendering
were previously missing direct coverage for:

- `multi_importer_pruner_marks_shared_dep_non_optional_when_any_importer_reaches_via_prod`
  ports the spirit of pnpm's
  [`pruneSharedLockfile`](https://github.com/pnpm/pnpm/blob/d8a79a9c30/lockfile/pruner/src/index.ts#L17)
  cross-importer pooling: a depPath reached via a non-optional path
  from any importer ends up `optional: false` even when another
  importer reaches it only via an optional path.
- `workspace_sibling_link_renders_per_importer_with_link_ref`
  exercises the multi-importer `workspace:`-link case — importer A
  depends on importer B via a `link:`-resolved depPath, both render
  their own `importers[<id>]` entries, and the link node stays out
  of `packages:` / `snapshots:`.

* fix(package-manager): skip undeclared aliases from pruner BFS seeds

Addresses CodeRabbit's review on PR #11905. Pacquet's resolver hoists
auto-installed peers into `direct_dependencies_by_alias` even when
they aren't in the importer's manifest (see
`resolve_importer::direct.extend(...)` after each `hoist_peers` call).
`build_importer` correctly excludes those undeclared aliases from the
importer's lockfile entry, but `compute_corrected_optional` was
seeding the pruner BFS from the full `direct_dependencies_by_alias`
and defaulting unknown aliases to `DependencyGroup::Prod`. That
diverges from upstream's
[`pruneSharedLockfile`](https://github.com/pnpm/pnpm/blob/d8a79a9c30/lockfile/pruner/src/index.ts#L27-L29),
which seeds purely from `lockfile.importers[*].{dev,optional,}dependencies`
— i.e., from the same set `build_importer` writes. The mismatch
forced auto-peers reachable only via an optional parent's chain to
`optional: false`, leaking them into non-optional installs.

Skip aliases not in the manifest when seeding. The new test
`auto_installed_peer_not_declared_in_manifest_is_skipped_from_pruner_seeds`
pins the corrected behavior — `peer-x` (auto-installed for an
`optionalDependencies` parent) stays `optional: true`, matching pnpm.
Verified the test fails against the pre-fix code.

Also tightens the multi-importer integration test's lockfile
assertion: scope the `hello-world-js-bin-parent` check to the
`packages/a:` importer section instead of a global substring match,
so the test proves the direct-dep entry — not just any mention in
`packages:`.

* fix(package-manager,store-dir): ensure store root exists before registering project

CI failure: `fresh_install_honors_enable_global_virtual_store` started
failing after the previous register_project consolidation. Two
compounding bugs:

1. `register_project` now runs early in `Install::run`, before any
   install phase has materialized the store. With the test's
   relative `storeDir: ../pacquet-store` in `pnpm-workspace.yaml`,
   `config.store_dir.root()` ends up shaped like
   `<workspace>/../pacquet-store/v11` — a path that doesn't yet
   exist on disk.

2. `path_contains`'s "lexical fallback" wasn't actually lexical —
   it called `dunce::canonicalize`, and on failure (path doesn't
   exist) it kept the path verbatim and ran `starts_with`. So
   `<workspace>/../pacquet-store/v11`.starts_with(`<workspace>`)
   returned true, the early-return guard fired, and the call
   silently skipped without writing the registry entry.

Two-part fix matching upstream:

- `Install::run` now calls `fs::create_dir_all(store_dir.root())`
  before `register_project`, mirroring pnpm's
  [`fs.mkdir(opts.storeDir, { recursive: true })`](https://github.com/pnpm/pnpm/blob/d8a79a9c30/installing/context/src/index.ts#L125)
  call right before `registerProject`. Once the store exists,
  `canonicalize` succeeds and `path_contains` resolves both sides
  correctly.

- `path_contains` now lexically normalizes `.` / `..` components
  when canonicalize fails. Matches upstream's `is-subdir` semantics
  (which uses `path.relative`, purely lexical). New test
  `path_contains_resolves_parent_components_when_paths_do_not_exist`
  pins the behavior; verified it fails against the pre-fix code.

* style: cargo fmt

* fix(package-manager,store-dir): satisfy Perfectionist lint and harden lexical_normalize

Two issues:

1. The multi-line `assert!` in
   `multi_importer_pruner_marks_shared_dep_non_optional_when_any_importer_reaches_via_prod`
   was missing its trailing comma after `cargo fmt` reformatted it from
   one-line to multi-line. Perfectionist's `macro-trailing-comma` rule
   (which CI enforces via Dylint) flagged it. Added the comma.

2. CodeRabbit pointed out that `lexical_normalize` silently dropped
   leading `..` components because `PathBuf::pop()` is a no-op when the
   path is empty. For the current `path_contains` callers (both inputs
   are absolute paths) this doesn't matter, but the helper is now a
   general-purpose utility and the bug would bite any future caller
   passing a relative path.

   Replaced the naive `out.pop()` with a match on the trailing
   component:
   - `Component::Normal(_)` → pop (real segment collapses with `..`)
   - `Component::RootDir | Prefix(_)` → drop the `..` (`/..` is `/` per
     POSIX)
   - else → push `..` (preserve leading `..` chain in relative paths)

   Matches Go's `path.Clean` semantics. New test
   `lexical_normalize_handles_parent_dir_corner_cases` pins all four
   corner cases.
2026-05-25 13:11:53 +02:00
mehmet turac
0721d64188 fix: require provenance for trusted publisher evidence (#11911)
* fix: require provenance for trusted publisher evidence

* test: align provenance fixtures with registry types

* chore: include pnpm CLI in changeset

The repo guideline requires every changeset that touches a published
package to list the pnpm CLI explicitly so the fix appears in the CLI's
release notes.

* fix(resolving-npm-resolver): require provenance for trusted publisher evidence

Ports pnpm's fea5fd41da: `get_trust_evidence` now only returns
`TrustedPublisher` when the version carries both
`_npmUser.trustedPublisher` *and* `dist.attestations.provenance`.
Without the attestation, the publisher flag is metadata a staged
publish could mint, so it can't be ranked above plain provenance.

Refs #11887.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-25 12:52:35 +02:00
mehmet turac
e8b3ae132e fix: clarify non-root resolutions warning (#11912) 2026-05-25 12:31:17 +02:00
Zoltan Kochan
494cdcaa01 chore: drop verdaccio from the repo (#11925)
The TS test harness (`__utils__/jest-config/with-registry/globalSetup.js`)
already launches `pnpm-registry`, and pacquet's `RegistryMode::Verdaccio`
spawns `pnpm-registry` too (the enum variant is a misnamed leftover). The
verdaccio dependency was only there to satisfy `@pnpm/registry-mock`'s
peerDependency declaration — nothing in this repo invokes verdaccio at
runtime.

Remove the catalog entry, the stranded `verdaccio.yaml` config, and the
`@verdaccio/auth` packageExtensions block. Mark `@pnpm/registry-mock`'s
verdaccio peer optional so pnpm doesn't auto-install it (and the entire
`@verdaccio/*` tree) across the workspace. Lockfile drops ~1100 lines.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-25 11:15:30 +02:00
Zoltan Kochan
d8a79a9c30 feat(registry): add auth/dist-tag/publish endpoints + wire TS tests onto pnpm-registry (#11914)
Lands the pieces of the npm registry protocol that pnpm-registry was missing, and switches the TypeScript test harness off verdaccio onto pnpm-registry. `@pnpm/registry-mock` (the npm package) is untouched.

### Server-side additions (`registry/crates/pnpm-registry`)

- `PUT /-/user/org.couchdb.user:<name>` — adduser / login, returns a Bearer token. In-memory user + token stores.
- `PUT /:pkg` — publish (scoped + unscoped). Base64-decodes `_attachments`, merges into the existing packument, writes manifest + tarball atomically. 100 MiB body limit.
- `GET /-/package/:pkg/dist-tags` + `PUT/DELETE /-/package/:pkg/dist-tags/:tag` — rewrites the on-disk packument so tag changes survive a restart.
- `Authorization: Bearer` and `Authorization: Basic` both identify the caller.
- Per-package access policy (wax glob patterns). Defaults mirror `@pnpm/registry-mock`'s `config.yaml`: `@private/*` and `@pnpm.e2e/needs-auth` require auth; everything else is anonymous read, authenticated write. Enforced on every packument / version-manifest / tarball GET and every write endpoint.

### TypeScript-test migration

- `__utils__/jest-config/with-registry/globalSetup.js` keeps `prepare()` from `@pnpm/registry-mock` (still needed for the tempy storage path written into the runtime-config yaml — `getIntegrity` reads it from there) but spawns `pnpm-registry` instead of verdaccio. `addUser`, `addDistTag`, `getIntegrity`, `REGISTRY_MOCK_*` from registry-mock work as-is — they're plain npm-wire-protocol HTTP calls.
- Binary lookup follows pacquet's pattern: `PNPM_REGISTRY_BIN` env override, then `target/release/pnpm-registry`, then `target/debug/pnpm-registry`.
- CI test job (`.github/workflows/test.yml`) installs the Rust toolchain via the existing `./.github/actions/rustup` composite action and builds `pnpm-registry --release` before tests run. Per-platform — Linux and Windows in the matrix each build their own.
2026-05-25 09:40:09 +02:00
Zoltan Kochan
058f5f2f8b fix(package-manager): port pnpm's lockfile-pruner BFS to re-derive transitive optional (#11919)
The resolver's per-node AND-fold updates only the directly-revisited
package, so descendants walked first via an `optionalDependencies`
edge stay stuck at `optional: true` even when a later non-optional
path reaches them transitively. Upstream hides this from users by
re-deriving the flag in `copyDependencySubGraph`; pacquet had no
equivalent pass, so a fetch or build failure on a transitively-required
package was silently tolerated as if it were optional.

Port the BFS into `dependencies_graph_to_lockfile`: walk from the
importer's direct deps (classified by manifest dep-group), recurse
through each node's children with parent-inherited optional for
regular edges and forced `optional: true` for `optionalDependencies`
edges, then override `SnapshotEntry.optional` to `false` for any
package reached by an all-non-optional path.

Refs https://github.com/pnpm/pnpm/issues/11916.
2026-05-25 01:35:23 +02:00
Zoltan Kochan
3788a8b0e6 perf(pacquet): lazy children realization in dependency tree (#11915)
* perf(resolving-deps-resolver): defer per-occurrence child realization until peer resolution

Mirrors upstream pnpm's lazy `children` thunk on `DependenciesTreeNode`:
revisits of a `pkgIdWithPatchHash` no longer recurse to fan out a fresh
NodeId subtree eagerly. Instead the tree node records
`TreeChildren::Lazy { parent_ids }` and the peer resolver allocates
per-occurrence child NodeIds on first descent via `realize_children`,
applying the same `parentIdsContainSequence` cycle break upstream uses
in `buildTree`.

Pure subtrees that the peer resolver already short-circuits through
`purePkgs` (ported in #11906) now skip realization entirely — the tree
never gets walked past the first occurrence of those packages.

Bench (astro deep tree, cold store, single resolver phase):
- tree nodes: 74,940 → 4,069 (~18× smaller)
- `resolve_importer`: 11.6s → 8.2s (~1.42× faster)

Refs #11907.

* fix(resolving-deps-resolver): fix doc + dylint failures from #11915

- Re-export `TreeChildren` and `ChildEdge` from the crate root so the
  intra-doc links from public docs (`resolve_peers`, `ResolvedTree`)
  resolve. They were `pub` on the enum/struct but unreachable because
  `resolved_tree` is a private module.
- Drop the `[Walker::realize_children]` / `[Walker::pure_pkgs]`
  intra-doc references from `resolve_peers`'s public doc — `Walker`
  is private, so the links failed under `--document-private-items`
  with `-D rustdoc::private_intra_doc_links`. The prose still names
  the items in plain backticks.
- Rename closure params `p` and let binding `v` in `realize_children`
  to satisfy `perfectionist::single-letter-{closure-param,let-binding}`.

* fix(resolving-deps-resolver): persist first-walk is_leaf for lazy realization

Mirrors upstream's
[`ResolvedPackage.isLeaf`](https://github.com/pnpm/pnpm/blob/b9de85dcb6/installing/deps-resolver/src/resolveDependencies.ts#L250)
field: `pkg_is_leaf(&result)` is computed once on the first walk and
stored on `ResolvedPackage::is_leaf`; the peer-resolver's
`realize_children` reads it back instead of inferring leaf-ness from
`children_by_id.is_empty() + peer_dependencies.is_empty()`.

The inferred check was a weaker approximation — a package with a
missing manifest (e.g. a git/tarball/local resolution where `result.
manifest` is None) lands on `pkg_is_leaf == false` in the eager walk
but on `is_leaf == true` in the realize path, which would collapse
distinct per-occurrence `NodeId`s onto a shared `NodeId::leaf` and
break the peer resolver's per-call-site state.

Matches upstream's
[`buildTree` consumption](https://github.com/pnpm/pnpm/blob/b9de85dcb6/installing/deps-resolver/src/resolveDependencyTree.ts#L381):
`ctx.resolvedPkgsById[child.id].isLeaf` is the source of truth, not
recomputed per realization.

* style(resolving-deps-resolver): apply cargo fmt for is_leaf binding

* test(resolving-deps-resolver): cover lazy children edge cases

Adds two regression tests for the lazy-children mechanism introduced
in #11915 that the existing coverage didn't hit:

1. `revisit_with_no_manifest_child_keeps_per_occurrence_node_id` —
   a child whose first walk produced `result.manifest == None`
   (the shape git / tarball / local resolvers return) must keep the
   non-leaf classification on every lazy realisation. Without the
   `ResolvedPackage::is_leaf` persistence the realizer would mis-
   classify it as a leaf and collapse distinct occurrences onto a
   shared `NodeId::Leaf`, breaking per-call-site state.

2. `pure_revisit_leaves_lazy_children_unrealized` — a pure pkg
   reached through multiple parents only realises its children for
   the occurrence the peer resolver walks first. Subsequent
   occurrences hit the `purePkgs` short-circuit before
   `realize_children` runs, so their `TreeChildren::Lazy` stays
   Lazy. Regression guard against accidentally moving the realise
   call above the short-circuit.

Both tests were validated by breaking the relevant subject (swapping
`is_leaf` back to the inferred check; moving `realize_children`
above the `purePkgs` gate) and confirming they fail cleanly.

* style(resolving-deps-resolver): drop hyphenated mis- in test docs

CI's typos pass at .typos.toml flags `mis-` (suggests "miss" /
"mist"). Use "misclassify" instead of "mis-classify" — same
word, no hyphen, no typo hit.
2026-05-25 00:02:50 +02:00
Zoltan Kochan
b9de85dcb6 ci(pacquet): drop pnpm comparison and self-compare on main from integrated-benchmark (#11913)
The pnpm-CLI baseline was useful while pacquet was slower than pnpm; now
that pacquet is the perf target itself, comparing against pnpm on every
run is noise. Drop --with-pnpm from every scenario step.

When the workflow runs on main, HEAD and main point at the same commit,
so a HEAD-vs-main comparison is wasted work. Resolve the target list at
job level: pacquet@HEAD on main, pacquet@HEAD pacquet@main everywhere
else (PRs, workflow_dispatch from non-main). The Bencher upload already
filters to pacquet@HEAD, so the single-target result still lands on the
main baseline as before.
2026-05-24 22:12:29 +02:00
Zoltan Kochan
f5d7723f3a perf(pacquet): port pnpm's purePkgs + peersCache for peer resolution (#11906)
* test(resolving-deps-resolver): port four peer-resolution cases from pnpm

Pacquet's `mod peers` test block had five tests, all of which exercise
single-occurrence happy paths. None covered the harder branches the
upstream resolver is designed to handle — cycles, packages reached
twice with divergent peer scope, parallel peer chains, transitive
peer issues. That gap left the peer resolver under-tested even for
the current algorithm, and would have made it dangerous to land
the `peersCache` + `purePkgs` ports tracked in #11907 because the
new cache lookup short-circuits exactly the branches no existing
test exercises.

Port four resolver-layer cases from
[`installing/deps-resolver/test/resolvePeers.ts`](https://github.com/pnpm/pnpm/blob/c86c423bdc/installing/deps-resolver/test/resolvePeers.ts):

- `cyclic_peer_dependencies_resolve_cleanly` — four-way cycle (foo
  ↔ bar ↔ qar ↔ zoo), every node lands in the graph without the
  walker panicking on cycle re-entry. Upstream `:14`.
- `revisit_resolves_peer_in_one_occurrence_misses_in_other` — same
  package reached via two parent chains, one where the peer
  resolves and one where it's missing; both occurrences must
  surface with distinct depPaths. Upstream `:128`.
- `two_peer_chains_resolve_against_their_own_sibling` — two parallel
  pkg-with-peer chains in the same importer; each picks its own
  sibling, no cross-pollination. Substitutes for upstream's
  `'resolve peer dependencies with npm aliases'` (`:573`) since
  npm-alias plumbing isn't yet wired through the test stub
  resolver — the TODO captures the gap so a follow-up can swap in
  the alias form once it lands.
- `bad_peer_inside_subtree_records_resolved_from_parent` — a peer
  reachable through a subdependency but at the wrong version
  surfaces as a *bad* peer, not a missing one. Stands in for
  upstream's `'unmet peer dependency issue resolved from
  subdependency'` describe-block (`:502`); the `resolvedFrom`
  field upstream tracks isn't exposed on pacquet's
  `PeerDependencyIssue` yet, so the test asserts the bad/missing
  classification only.

All four pass on `main`. Together they exercise the parent-context
matching that future cache optimizations (#11907) need to get right
— the second test in particular drives a shared-subtree shape where
a naive NodeId-keyed cache returns stale depPaths.

Refs #11907.

* perf(resolving-deps-resolver): port pnpm's purePkgs + peersCache for peer resolution

The peer resolver was rewalking every `NodeId` in the tree from
scratch and recomputing the full per-package peer set on each
hoist-loop iteration. On the `astro@^5` install (~1.6k unique
packages, deep transitive shape) that pushed `resolve_peers` to
3.8 s of an 8.5 s `resolve_importer` phase — pacquet had already
recorded `is_pure` per graph node but wasn't using it as a cache
key, and `peersCache` was deferred in the original slice.

Port both upstream optimizations and remove the unsafe
`node_dep_paths` shortcut at the top of `resolve_node` that
silently returned stale `depPath`s when the same shared `NodeId`
got walked under two different parent peer contexts (an
inevitable shape post-isNew-gate, and the bug
`revisit_resolves_peer_in_one_occurrence_misses_in_other` catches).

## `purePkgs` fast path

A `HashSet<String>` of `pkgIdWithPatchHash` values whose full
subtree resolved with zero external peers and zero missing peers.
Populated bottom-up: a node is added when `is_pure` is true after
its own walk completes. A revisit of any pure pkg whose own
`peerDependencies` is empty short-circuits with
`depPath = pkgIdWithPatchHash` — no recursion, no peersCache
lookup. Mirrors upstream's
`purePkgs` early-return (resolvePeers.ts:398-406 at c86c423bdc).

## `peersCache` + `find_hit`

A `HashMap<String, Vec<PeersCacheItem>>` keyed by
`pkgIdWithPatchHash`. Each cache item carries the `depPath`, the
external `(peer_name → NodeId)` map, and the `(peer_name → info)`
missing set produced by one specific parent peer context.
`find_hit` iterates the bucket and accepts the first item whose
cached resolved-peer `NodeId`s map to packages with the same
`pkgIdWithPatchHash` in the current `child_parent_refs`, and
whose cached missing-peer set doesn't intersect the current
parents. Mirrors upstream's `peersCache` + `findHit`
(resolvePeers.ts:342-348 + 660-699 at c86c423bdc).

The simplified port omits upstream's `parentPackagesMatch` deep
check (which compares the cached and current parent peer chains
via `parentPkgsOfNode`). The current ported tests pass without
it; the deeper check is tracked separately in #11907 along with
the rest of the peer-resolution port.

## Result

Removes the buggy `node_dep_paths` shortcut at the top of
`resolve_node`. All 54 tests pass — including
`revisit_resolves_peer_in_one_occurrence_misses_in_other` which
catches the exact wrong-depPath regression that doomed the
earlier shared-NodeId approach.

Bench on `astro@^5` (macOS, hot store, no lockfile, hyperfine
warmup=1 runs=5, store pre-warmed identically):

|                      | mean              | range            |
|----------------------|------------------:|-----------------:|
| pacquet@main         | 10.929 ± 0.810 s  | 9.90 – 12.17 s   |
| **pacquet+peersCache** | **7.250 ± 0.322 s**  | **6.85 – 7.74 s** |

**1.51× faster than main on the targeted fresh-resolve path**, on
a correct algorithm that matches pnpm. The remaining gap to pnpm
on the same fixture (~1 s) is partly the `parentPackagesMatch`
deep check (#11907) and partly first-visit resolver work that
neither cache addresses.

Refs #11900, #11907.

* docs(resolving-deps-resolver): unlink local-var reference in pure_pkgs doc

The `[is_pure]` link points at a local `let is_pure = …` binding
inside `resolve_node`, which isn't an item rustdoc can resolve.
`-D rustdoc::broken_intra_doc_links` rejects it on the doc job.

* fixup: address CodeRabbit review on the peersCache port

Three correctness items + one nitpick from the auto-review:

1. **`parent_packages_match` deep check**. `find_hit` was checking
   only the immediate resolved-peer `NodeId` / `pkgIdWithPatchHash`
   match, which can still return stale cached entries when the peer
   itself has different per-occurrence ancestor chains. Port
   upstream's [`parentPackagesMatch`](https://github.com/pnpm/pnpm/blob/c86c423bdc/installing/deps-resolver/src/resolvePeers.ts#L701-L731):
   - Track per-`NodeId` parent peer context in
     `Walker::parent_pkgs_of_node`, populated at descend-time by
     `parent_dep_paths_from_refs` (mirrors upstream's
     `parentPkgsOfNode.set(childNodeId, parentDepPaths)` block at
     `resolvePeers.ts:817-832`).
   - Add `Walker::parent_packages_match` that compares two
     `NodeId`s' recorded contexts: same set of peer-relevant names,
     each name mapping to the same `(pkg_id, version)`, plus
     upstream's depth-equality / `pure_pkgs` fallbacks when peers
     are shadowed.
   - Extend `ParentRef` with `depth` and `occurrence` so the deep
     check has the inputs it needs. New `bump_occurrence_on_shadow`
     helper does the same-name shadowing bookkeeping upstream's
     `addParentPkg` arm does at `resolvePeers.ts:430-439`.
   - `find_hit` now calls `parent_packages_match` whenever cached
     and current peer `NodeId`s differ but their pkg ids match,
     skipping the deep check only when `pure_pkgs` makes it
     vacuous (matches upstream).

2. **Depth tie-break on the fast returns**. The `pure_pkgs`
   short-circuit and the `find_hit` cache-hit branch both returned
   early without running the existing
   `self.graph.entry(dep_path).and_modify(|n| if n.depth > … { … })`
   tie-break. A shallower revisit through the cache would leave the
   graph entry's `depth` stuck at the first walk's value. Both
   branches now lower `depth` in place when the current occurrence
   reaches the package at a smaller depth.

3. **Module-doc lead-in contradiction**. The header still said
   `peersCache` / `purePkgs` were "not ported yet"; updated to
   reflect that both land here and `find_hit` + the deep check
   together implement the `peersCache` lookup. The only deferred
   optimisation called out is the `graph-cycles`-driven async
   deferment, which pacquet substitutes with the synchronous
   `in_progress` set.

4. **Test docs duplicated test bodies** (nitpick). The three new
   peer-resolution tests' doc comments narrated the setup + expected
   output already encoded in the assertions. Trimmed each to a
   one-line "why" + upstream reference, per pacquet's CODE_STYLE_GUIDE
   "tests are documentation" rule.

Bench on `astro@^5` (macOS, hot store, hyperfine warmup=1 runs=5):

| | mean | speedup vs main |
|---|---:|---:|
| pacquet@main | 11.346 ± 1.262 s | 1.00× |
| pacquet+peersCache (deep check) | 9.890 ± 1.651 s | ~1.15× |

The deep check rejects some cache hits the simpler `find_hit`
accepted, so the perf number is below the earlier 1.51× claim — but
the algorithm now matches pnpm. The earlier simplified port was a
divergence; correctness over speed.

All 54 tests pass, clippy + fmt + `RUSTDOCFLAGS=-D warnings cargo doc`
clean.
2026-05-24 21:18:26 +02:00
Zoltan Kochan
add6c794f1 feat(registry): implement pnpm-registry server and adopt it in pacquet's test mock (#11898)
Creates a working pnpm-compatible npm registry server (verdaccio analogue, in Rust) — and replaces `@pnpm/registry-mock`'s Node + Verdaccio launcher in pacquet's test setup with the new binary, against `@pnpm/registry-mock`'s shipped storage.

### What `pnpm-registry` does
- **HTTP server** (axum + tower-http) with the three endpoints pnpm/npm clients need:
  - `GET /<pkg>` — packument (`/{name}` and `/{scope}/{name}`)
  - `GET /<pkg>/<version-or-tag>` — single-version manifest, resolves `dist-tags` and rewrites `dist.tarball` to point at this server
  - `GET /<pkg>/-/<tarball>` — tarball, streamed
- **Two modes:**
  - **Proxy** — fetches missing packuments/tarballs from a configurable upstream (defaults to `https://registry.npmjs.org`), caches to disk
  - **Static** (`--static`) — serves the storage directory verbatim, 404s on cache miss
- **Verdaccio-shaped on-disk storage** (`<root>/<pkg>/package.json` + flat tarballs) — drop-in compatible with the storage `@pnpm/registry-mock` publishes
- **Tarball streaming** — cache hits stream off disk; cache misses tee upstream chunks into a temp file via an mpsc channel and forward them to the client at the same time, atomically renaming on success and abandoning on upstream error or client disconnect
- **Tuned HTTP client** — wraps `pacquet_network::ThrottledClient::new_for_installs()`, inheriting pnpm's tuned defaults (`User-Agent: pnpm`, HTTP/1.1, hickory DNS, connection-pool tuning, concurrency semaphore)
- **Gateway-style status mapping** — `is_timeout()` → 504, `is_connect()` → 503, everything else (incl. upstream 5xx) → 502. No proxy-side retry (the pnpm client already has `fetch-retries`; stacking retries would only multiply latency on real failures).

### What changed in pacquet
- `pacquet/tasks/registry-mock` now spawns `pnpm-registry` against `node_modules/@pnpm/registry-mock/registry/storage-cache` (proxy mode with `npmjs.org` upstream and a 1-year packument TTL — matching `@pnpm/registry-mock`'s `'**': proxy: npmjs` verdaccio config). No more Node, no more Verdaccio, no more `launch.mjs`, no more process-tree walk to kill child verdaccios.
- `@pnpm/registry-mock` stays as a devDep — only for the storage data it ships, not the launcher.

### Tests
- **36 pnpm-registry tests** (12 unit + 7 against `@pnpm/registry-mock` storage in static mode + 17 mockito-based proxy/cache/streaming): packument rewrite, version-manifest resolution, tarball streaming (large body, cache finalize, mid-stream upstream error, client disconnect mid-stream, concurrent fetches → one cache file), gateway status mapping (504/503/502), stale-cache fallback on upstream failure, TTL refresh, invalid-package-name 400, scoped vs unscoped routing.
- **Full pacquet test suite** (2043 tests) runs green against `pnpm-registry`-backed mock.

### CI
- `pacquet-ci.yml` and `pacquet-codecov.yml` path filters now include `registry/**` (so registry-only PRs trigger the workspace CI); typos checker covers `registry` too. The workflow name stays "Pacquet CI" but a header comment explains the intentional cross-stack scope.
- `just registry-mock launch` pre-builds with `cargo nextest run --no-run` (workspace-wide) so its fingerprint matches what `just test` will later need — without this, Windows MSVC fails with `os error 5` trying to re-link the running `pnpm-registry.exe`.

### Crates.io name reservations (from the original scaffold commit)
- [`pnpm-registry`](https://crates.io/crates/pnpm-registry) — published from this repo
- [`pnpm-registry-cli`](https://crates.io/crates/pnpm-registry-cli) / [`pnpm-registry-server`](https://crates.io/crates/pnpm-registry-server) — placeholder stubs, name reservation only
2026-05-24 21:18:09 +02:00
Zoltan Kochan
e549cd1cf1 perf(pacquet): no-op short-circuit when node_modules is up to date (#11904)
* perf(pacquet): no-op short-circuit when node_modules is up to date

Adds a fast-path gate in `Install::run` that mirrors upstream pnpm's
`validateModules` + `allProjectsAreUpToDate` shortcut: when the
frozen-lockfile dispatch is eligible, `.modules.yaml` agrees with the
current config, and `<virtual_store_dir>/lock.yaml` is byte-equal to
the wanted lockfile, skip materialization entirely. The install emits
the `name: "pnpm" / level: "info"` "Lockfile is up to date,
resolution step is skipped" log, refreshes the workspace-state
timestamp so `pnpm run`'s `verifyDepsBeforeRun` doesn't fire
spuriously, and returns.

Closes #11899.

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

* fix(pacquet): tighten short-circuit test assertions

Address review feedback:

- Use `r#"..."#` raw-string for the up-to-date log assertion message
  so dylint's `perfectionist::prefer-raw-string` lint stops flagging
  the escaped quotes inside it.
- Loosen the "up-to-date log fires" check to `any(|e| matches!(...))`
  so unrelated future `LogEvent::Pnpm` emits don't make the test
  brittle.
- Swap `Path::exists()` for `std::fs::symlink_metadata().is_err()`
  on the "link: dep not materialized" assertion so a dangling
  symlink (which `exists()` reports as `false`) wouldn't sneak past.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-24 18:35:59 +02:00
mehmet turac
a456dc78fb fix(list): limit manifest reads for large workspaces (#11692) 2026-05-24 11:45:40 +02:00
shiminshen
572842a039 fix(installing.commands): clarify "loose mode" wording in minimumReleaseAge log (#11763)
* fix(installing.commands): clarify "loose mode" wording in minimumReleaseAge log

The log line printed when pnpm auto-adds entries to
`minimumReleaseAgeExclude` referred to internal "loose mode" terminology,
which doesn't appear in the docs and isn't discoverable. Point users at
the actual setting name they need to flip.

Closes #11747

* Update installing/commands/src/policyHandlers.ts

Co-authored-by: Zoltan Kochan <z@kochan.io>

* fix(installing.commands): name the value in minimumReleaseAgeStrict log hint

Change "set minimumReleaseAgeStrict to gate these updates with a prompt"
to "set minimumReleaseAgeStrict to true to ..." so the value is explicit.

---------

Co-authored-by: shiminshen <16914659+shiminshen@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-24 11:02:42 +02:00
Zoltan Kochan
9a3207367d chore: update pnpm and pacquet 2026-05-24 10:46:22 +02:00
Zoltan Kochan
bcbc008f2d fix: temporarily disabling pacquet for release v11.3.0 2026-05-24 10:36:14 +02:00
Zoltan Kochan
6316e7b275 fix(deploy): skip configDependencies in the nested install (#11895)
* fix(deploy): skip configDependencies in the nested install

The deploy directory never installs configDependencies, so the install
engine they designate (e.g. pacquet) isn't on disk to invoke. Without
this override, `pnpm deploy` crashes with `ENOENT: ... lstat
'<deployDir>/node_modules'` when the workspace declares pacquet under
`configDependencies`.

* test(deploy): cover deploy with pacquet in configDependencies

Reproduces the ENOENT crash that happens when `deployFromSharedLockfile`
forwards the workspace's `configDependencies` (e.g. pacquet) into its
nested install and the install engine tries to spawn from
`<deployDir>/node_modules/.pnpm-config/`.

* test(deploy): clarify the public-registry comment in the pacquet deploy test
2026-05-24 10:31:31 +02:00
Zoltan Kochan
74a219eac6 fix(pacquet/modules-yaml): write GVS-aware virtualStoreDir to match pnpm (#11896)
* fix(pacquet/modules-yaml): write GVS-aware virtualStoreDir to match pnpm

Under `enableGlobalVirtualStore: true`, upstream pnpm mutates
`virtualStoreDir` in place at `extendInstallOptions.ts:419-422` so
every consumer that reads `ctx.virtualStoreDir` — including
`writeModulesManifest` and the `pnpm:context` debug log — sees the
GVS-derived `<storeDir>/v11/links` path.

Pacquet kept `Config::virtual_store_dir` at its project-local value
(deliberately, see `apply_global_virtual_store_derivation`'s rationale)
and wrote that field straight into `.modules.yaml` and the context
log. With `pnpm install` delegating to pacquet via `configDependencies`,
every run came back through pnpm's `checkCompatibility`, the recorded
project-local `virtualStoreDir` didn't match the GVS-mutated value
pnpm computed, and per-importer purges fired the
"modules directories will be reinstalled from scratch" prompt on
every install.

Route both externally-visible consumers through a new
`Config::effective_virtual_store_dir` helper that returns
`global_virtual_store_dir` when GVS is on (which already encodes
"user pinned or fall back to `<storeDir>/links`" via
`apply_global_virtual_store_derivation`) and the project-local
`virtual_store_dir` otherwise. Pacquet's internal layout consumers
still read the field directly — the divergence the helper bridges
is only at the parity boundary.

Test pins both halves: `.modules.yaml` round-trips to
`<storeDir>/v11/links` under GVS, and the `pnpm:context` event
reports the same path.

* fix(pacquet/store-dir): build modules_yaml expected path with Path::join so the test passes on Windows

`modules_yaml_serialized_store_dir_carries_store_version` (added in
3209c2510c) hardcoded `/tmp/.pnpm-store/v11` on the right-hand side
while the left-hand side flows through `StoreDir::from`'s
`PathBuf::join`. On Windows that join emits `\v11`, so the test
panicked with `\v11` vs `/v11` — and has been failing in the
post-merge `Pacquet CI` run for #11891 since it landed.

Pnpm itself uses Node's `path.join` (via `getStorePath` →
`path.join(storePath, STORE_VERSION)`), which is also
backslash-joined on Windows. Building the expected value through
`Path::join` here mirrors that path and keeps the test asserting the
real parity contract on every platform.
2026-05-24 10:31:13 +02:00
Zoltan Kochan
f2a4d2caef chore(release): 11.3.0 (#11894) 2026-05-24 02:23:07 +02:00
modten
3b62f9da31 feat(publish): add --skip-manifest-obfuscation flag for pack/publish (#11393)
* feat(publish): add preserve-manifest-fields option

* fix(publish): omit pnpm field when preserveManifestFields is enabled

The preserve-manifest-fields option was deep-cloning the entire manifest,
which leaked the pnpm-specific `pnpm` field into packed/published manifests.
The PR description explicitly calls for this field to remain stripped;
align the implementation, tests, help text, and changeset accordingly.

* refactor(publish): rename preserve-manifest-fields to skip-manifest-obfuscation

The original name implied the flag preserves *all* manifest fields, which
isn't true — the pnpm-specific `pnpm` field is still stripped, and
`publishConfig` / workspace-protocol / catalog rewriting still happen. The
flag is really an escape hatch from pnpm's manifest mangling, so name it
that way. Help text and changeset updated to match.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-24 02:15:18 +02:00
Zoltan Kochan
cdceebc2ab chore: update pacquet to v0.2.7 (#11893) 2026-05-24 02:14:32 +02:00
Zoltan Kochan
155af87585 fix(env-installer): prune env lockfile when updating a config dep (#11892)
`pnpm add --config <pkg>` (via `resolveConfigDeps`) wrote the env
lockfile without pruning, so optional subdependencies from the
previously resolved version remained as orphans. Mirror the prune
call from `resolveAndInstallConfigDeps`.
2026-05-24 01:49:33 +02:00
Zoltan Kochan
3209c2510c fix(pacquet/store-dir): append STORE_VERSION to storeDir so pnpm and pacquet stay interchangeable (#11891)
* fix(store-dir): append STORE_VERSION to storeDir so pnpm and pacquet stay interchangeable

pnpm's `getStorePath` appends `STORE_VERSION` (`"v11"`) to whatever the
user configured, so the `.modules.yaml` it writes records the v11-suffixed
path. pacquet stored the suffix only as an internal sub-path accessor
(`StoreDir::v11`), which meant `config.store_dir.display()` — the value
pacquet writes to `.modules.yaml`, prints from `pacquet store path`, and
emits in the NDJSON `context` log — yielded the un-suffixed parent.
Switching between the two CLIs in the same project tripped pnpm's
`checkCompatibility` with `ERR_PNPM_UNEXPECTED_STORE`.

Fix is centralised in `From<PathBuf> for StoreDir`, mirroring pnpm's
`if (endsWith(v11)) return; else append(v11)` branch at
store/path/src/index.ts:39-42. Every consumer reading from `StoreDir`
(`display()`, `root()`, `files()`, `tmp()`, `links()`, `projects()`)
now sees the v11-suffixed path through one source of truth, so the
on-disk layout is unchanged and the externally-reported `storeDir`
matches pnpm's exactly.

Ref: https://github.com/pnpm/pnpm/blob/29a42efc3b/store/path/src/index.ts#L39-L42

* fix(store-dir): satisfy Perfectionist macro-trailing-comma on remaining multi-line assert

* fix(store-dir): enforce STORE_VERSION suffix on deserialize via #[serde(from)]

CodeRabbit flagged that the previous `#[serde(transparent)]` derive on
`StoreDir` deserialised straight into `StoreDir::root`, bypassing the
auto-append in `impl From<PathBuf> for StoreDir`. A persisted unsuffixed
path would therefore violate the [`STORE_VERSION`] invariant on the live
struct until the next reconstruction. Pacquet doesn't currently
deserialize `StoreDir` from any disk shape, but the type-level guarantee
is part of the public contract — future serialised state must round-trip
through the suffix logic.

Route both directions through `PathBuf` with
`#[serde(from = "PathBuf", into = "PathBuf")]`. Deserialize now flows
through `From<PathBuf>` (which applies the suffix); serialize converts
to `PathBuf` and back to the same wire shape `transparent` produced, so
no on-disk format change. `Clone` is required by `into` and was added.

Also fix CodeRabbit's doc-comment nit at
project_registry::register_skips_when_store_is_inside_project — the
comment referenced `StoreDir::from` while the test calls `StoreDir::new`;
clarified that `new` routes through `From<PathBuf>`.

Added round-trip tests in `store_dir::tests`:
- `deserialize_applies_store_version_to_unsuffixed_path`
- `deserialize_preserves_already_suffixed_path`
2026-05-24 01:48:24 +02:00
Zoltan Kochan
e0bd879dea fix(deps-resolver): restore index-based pairing so git/tarball deps aren't dropped (#11890)
PR #11711 switched updateProjectManifest and the catalog-update loop in
resolveDependencies to look up wantedDependencies by alias, but
parseWantedDependency returns `{ alias: undefined, bareSpecifier }` for
inputs like `pnpm/foo#sha` or tarball URLs whose alias is only known
after fetching the package's package.json. Those entries collided under
the `undefined` Map key, so the alias-keyed lookup of the resolved dep
returned undefined, the filter dropped them from specsToUpsert, and they
silently disappeared from the manifest update and pendingBuilds.

This restored the index-based pairing the code used before #11711.
catalog: preservation isn't affected: it's driven by
rdd.catalogLookup.userSpecifiedBareSpecifier in the spec object, not by
how wantedDep is looked up.

The premise in the removed comment ("linked deps like workspace:* are
excluded from directDependencies") was also wrong — linked deps stay in
directDependencies with isLinkedDependency: true, they're not dropped.

Restores building/commands/test/build/index.ts: rebuilds dependencies,
rebuilds specific dependencies, rebuild with pending option.
2026-05-24 01:17:17 +02:00
Zoltan Kochan
4bcc268be8 chore: update node.js used for local development (#11889) 2026-05-24 00:42:49 +02:00
Totoro
ae42a7adc1 fix: preserve catalog: protocol references on upgrade (#11711)
* fix: preserve catalog: protocol references on upgrade (issue #11658)

* refactor: address review feedback on catalog: preservation fix

- Fix typo in 3 test assertions (`@pnpm.e2e.foo` → `@pnpm.e2e/foo`)
  that made `.toBeFalsy()` pass vacuously
- Use `Map` for alias→wantedDependency lookup in `updateProjectManifest`
  to match the pattern in `index.ts`

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-24 00:17:00 +02:00
Alessio Attilio
22cb743672 feat: implement native 'pnpm repo' command (#11505)
* feat: implement native 'pnpm repo' command

* fix(deps.inspection.commands): preserve repository.directory in fetchPackageInfo

`fetchPackageInfo` flattened `repository` to its URL string, dropping
`directory`. `pnpm repo <pkg>` therefore couldn't append the monorepo
subdirectory for registry packages even though `pickRepoUrl` supported
it. Keep the original repository value so the URL builder receives both
`url` and `directory`.

Also add the missing changeset for the `pnpm repo` command.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-24 00:14:04 +02:00
Alessio Attilio
d55263fff5 feat(pkg-manifest): add native set-script command with ss alias (#11504)
* feat: add native set-script command with ss alias

* refactor(pkg-manifest): host set-script and wire it into the CLI

- Move set-script into @pnpm/pkg-manifest.commands (drops the orphan
  @pnpm/pkg.commands package; pkg/* is not in the workspace).
- Use readProjectManifest from @pnpm/cli.utils so package.json5 and
  package.yaml are updated in place instead of growing a stray
  package.json.
- Remove set-script from notImplemented and register the command in
  pnpm/src/cmd/index.ts.
- Cover the ss alias and the multi-word command path in tests.

* refactor(set-script): share the pkg-set primitive

Replace direct manifest.scripts mutation with
setObjectValueByPropertyPath - the same primitive pkg-set uses. Reuses
the prototype-pollution rejection for free and keeps the two commands
on the same write path. Avoids the pkg-set string-CLI's first-equals
key/value split, so script names containing '=' work too.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-23 23:50:29 +02:00
Alessio Attilio
d7da112eea feat(pkg): implement native pnpm pkg command (#11512)
Implements `pnpm pkg` natively with `get`, `set`, `delete`, and `fix` subcommands.

Workspace usage follows pnpm conventions: use `-r` / `--recursive` for all selected workspace projects, and `--filter` to narrow the selected project graph. This does not add npm-style `--workspace` or `--workspaces` flags.

The PR also extends `@pnpm/object.property-path` with safe set/delete helpers used by the command.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-23 22:59:09 +02:00
Zoltan Kochan
389dae8382 ci: fix zizmor ref-version-mismatch on action-gh-release (#11888)
The dependabot bump to v3.0.0 updated the pinned commit hash but left
the trailing version comment as v2.5.0.
2026-05-23 22:48:09 +02:00
Zoltan Kochan
3d143854c0 fix(exec.commands): fall back to alias as bin name when dlx slot lacks package.json (#11886)
`getBinName` reads the installed package's `package.json` out of the
GVS slot to discover the bin name. On CI this read has been failing
intermittently for `node@runtime:24.6.0` with
`ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND` — the dlx install reports
`added 1, done`, but the slot the symlink points at has no
`package.json`. The bin link itself is fine (pnpm creates it from the
resolution's `bin` info, not from the slot's manifest), so the only
casualty is `getBinName`.

The slot can end up without `package.json` when something populated it
without going through pnpm's `appendManifest` synthesis (or pacquet's
runtime-manifest synthesis equivalent) — runtime archives don't ship
their own `package.json`, so the synthesized one is the only way it
gets there. Pacquet's `import_indexed_dir` short-circuits on existing
slots without checking which files are present, so a slot populated
by an older code path stays incomplete.

Catch the manifest-not-found error and fall back to the scopeless
package name. For single-bin packages that match `manifest.bin` (the
common case for `pnpm dlx <pkg>`, including every `runtime:` spec),
this gives the same answer the manifest would. Multi-bin packages
already require `--package=<spec> <bin>` to disambiguate, which
short-circuits `getBinName` upstream and never enters this branch.
2026-05-23 21:42:27 +02:00
dependabot[bot]
7a5cb92f80 chore(cargo): bump assert_cmd from 2.2.1 to 2.2.2 (#11853)
Bumps [assert_cmd](https://github.com/assert-rs/assert_cmd) from 2.2.1 to 2.2.2.
- [Changelog](https://github.com/assert-rs/assert_cmd/blob/master/CHANGELOG.md)
- [Commits](https://github.com/assert-rs/assert_cmd/compare/v2.2.1...v2.2.2)

---
updated-dependencies:
- dependency-name: assert_cmd
  dependency-version: 2.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 21:21:20 +02:00
kimulaco
43e06bb2ae fix(pacquet): accept string libc in PackageMetadata (#11880) 2026-05-23 20:40:25 +02:00
Jovi De Croock
508e6d800b feat: add pnpm stage command (#11863)
* feat: add npm stage command

* fix: correct stage command edge cases

* fix: handle stage error paths

* refactor: address stage command review feedback

- Type stageId via OtpPublishResponse so publishPackedPkg no longer needs a cast.
- Hoist fetchFromRegistry + auth header into a per-subcommand StageContext.
- Send npm-auth-type: web on all stage requests, not just approve/reject.
- Consolidate stageRequest / stageRequestWithOtp / stageJsonRequest into (context, params) form.

* fix: trigger stage OTP flow on web-auth challenges

The registry responds to stage approve/reject with 401 and a body of
`{ authUrl, doneUrl }` when the user must complete a browser-based
authentication, but `www-authenticate` does not contain "otp" in that
case. The previous check missed this and surfaced the response as a
generic STAGE_REGISTRY_ERROR. Detect web-auth responses by body shape so
withOtpHandling can drive the polling flow.

* test: cover stage approve web-auth detection paths

Add tests that lock in the OTP-trigger detection on the stage command:
- 401 with `{authUrl, doneUrl}` enters the web-auth flow, exercised here
  via the full polling-completion path (registry returns a token, the
  retry request carries it as npm-otp).
- 401 with web-auth body but no TTY surfaces as OTP_NON_INTERACTIVE.
- 401 without any OTP signals stays a STAGE_REGISTRY_ERROR so we don't
  over-trigger the OTP flow on unrelated unauthorized responses.

* fix: keep web-auth and OTP wrapper on stage publish

Calling `context.publish()` directly for staged publishes bypassed
`publishWithOtpHandling`, so users without a preconfigured token had no
path through the browser-based authentication flow on `pnpm stage
publish`. Route the staged publish through the same wrapper as the
regular publish; `OtpPublishResponse.stageId` carries the registry's
identifier when set.

* refactor: split stage into per-subcommand files and lift tarball helpers

The 631-line `stage.ts` carried six subcommands, tarball parsing,
auth/request plumbing, error class, OTP detection, and rendering helpers
in one file. Reorganized to match the existing `publish/` folder layout:

- `stage/{index,help,publish,list,view,approve,reject,download,context,
  request,parsing,rendering,errors,types}.ts` — one concept per file.
- `tarball/{publishSummary,summarizeTarball}.ts` — shared between
  `publish` and `stage` instead of duplicated. `PublishSummary` and
  `extractBundledDependencies` now live with the tarball helper rather
  than inside the publish subfolder, so other commands can reuse them
  without reaching into `publish/`.

Behavior unchanged. Also dropped `StageRegistryError`'s redundant
`statusCode` field (was identical to `status`) to bring it in line with
`FailedToPublishError`.

* chore: add TOTP and unparseable to cspell dictionary

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-23 20:35:43 +02:00
Zoltan Kochan
212315de16 fix: cap lockfile verification memory and add trustLockfile opt-out (#11878)
* fix: cap lockfile verification memory and add trustLockfile opt-out

Verifying a multi-thousand-entry lockfile against `minimumReleaseAge`
or `trustPolicy: no-downgrade` retained every fetched packument in a
per-install cache for the entire install. On large workspaces this
OOM'd CI runners with a 2GB heap cap. Project both caches down to just
the fields each check reads (per-version trust evidence + the `time`
map for trust; package-level `modified` + version-name set for the
abbreviated shortcut) so the bulk packument is GC'd as soon as the
fetch returns.

Also adds a `trustLockfile` setting (default `false`) that skips the
verification pass entirely for environments where the lockfile is
already part of the trusted base. Mirrored in pacquet. Closes #11860.

* perf: share resolver packument cache with the lockfile verifier

The verifier kept its own per-install dedup Maps and re-fetched every
packument the resolver had already pulled during the same install.
Plumb the resolver's per-install `PackageMetaCache` through to the
verifier (via `createNpmResolutionVerifier` / `build_resolution_verifiers`)
so a name already in the resolver's LRU short-circuits the verifier's
disk/network round-trip — fast path only, the cached document is
projected for the trust check so the verifier's memory footprint stays
bounded.

In pnpm, `installing/client` now constructs one LRU and hands it to
both `createResolver` and `createResolutionVerifiers`. In pacquet, the
`InMemoryPackageMetaCache` is lifted to `Install::dispatch` and passed
to both `build_resolution_verifiers` and `InstallWithFreshLockfile`.
2026-05-23 20:33:03 +02:00