Commit Graph
11575 Commits
Author SHA1 Message Date
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
dependabot[bot] a5b1ac783f chore(deps): bump the github-actions group with 4 updates (#11854)
Bumps the github-actions group with 4 updates: [github/codeql-action](https://github.com/github/codeql-action), [taiki-e/install-action](https://github.com/taiki-e/install-action), [garnet-org/action](https://github.com/garnet-org/action) and [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action).


Updates `github/codeql-action` from 4.35.4 to 4.35.5
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/68bde559dea0fdcac2102bfdf6230c5f70eb485e...9e0d7b8d25671d64c341c19c0152d693099fb5ba)

Updates `taiki-e/install-action` from 2.78.0 to 2.78.1
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/e1c4cd42111751368541a7cb5db3522bd1f846a4...184183c2401be73c3bf42c2e61268aa5855379c1)

Updates `garnet-org/action` from 2.0.1 to 2.0.2
- [Release notes](https://github.com/garnet-org/action/releases)
- [Commits](https://github.com/garnet-org/action/compare/9e819143e63d6dda04bca2e90ac85e3cf0e5289d...2b7fc9d79b54f551b43358c27424a36064b3e078)

Updates `zizmorcore/zizmor-action` from 0.5.3 to 0.5.5
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/b1d7e1fb5de872772f31590499237e7cce841e8e...a16621b09c6db4281f81a93cb393b05dcd7b7165)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: taiki-e/install-action
  dependency-version: 2.78.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: garnet-org/action
  dependency-version: 2.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 19:43:23 +02:00
Zoltan Kochan bf581bb0a5 ci(bencher): enforce PR thresholds and grant checks: write (#11883)
- Add `--start-point-clone-thresholds` to the non-main upload arms
  so PR/feature branches inherit thresholds configured on main; pair
  it with `--err` so a sample over the upper boundary fails the job.
- Add `checks: write` to the three workflows that call `bencher run`.
  On main pushes (no `--ci-number`, not a PR event) Bencher falls back
  to creating a GitHub Check on the commit; without the permission it
  exits 1 with "Failed to create GitHub Check".
2026-05-23 18:49:39 +02:00
Zoltan Kochan 4088de0433 ci(bencher): record benchmark results to Bencher (#11875)
* ci(bencher): record benchmark results to Bencher

Tracks both stacks in one Bencher project (`pnpm`), under separate
testbeds (`pnpm`, `pacquet`).

- `benchmarks/bench.sh` emits a hyperfine-shaped `bencher-results.json`
  combining the six pnpm scenarios.
- `benchmark.yml` adds `push: branches: [main]` so each merge updates
  the `pnpm` baseline, then uploads via the Bencher CLI.
- `pacquet-integrated-benchmark.yml` adds the same main-push baseline
  for `pacquet`, combines the four scenario JSONs into a Bencher
  report, and stages it into the existing artifact.
- `pacquet-integrated-benchmark-comment.yml` uploads PR results from
  the trusted `workflow_run` context so fork PRs are covered too.

Requires `BENCHER_API_TOKEN` in repo secrets; workflows no-op with a
`::notice::` if it's missing.

* ci(bencher): allow workflow_dispatch to upload pacquet results

The inline Bencher upload was gated to `event_name == 'push'`, which
meant manual dispatch from a feature branch ran the bench but skipped
the upload. Both push and workflow_dispatch execute in the base-repo
privilege context, so it's safe to upload from both — the fork-safety
gate only needs to keep `pull_request` runs out.

On dispatch from a non-main branch we record into the ref name with
`--start-point main --start-point-reset`, matching the pnpm bench's
branch policy.

* ci(bencher): treat workflow_dispatch on main as the main baseline

Two small fixes from PR review:

- `benchmark.yml`: manual dispatch from `main` was falling into the
  non-main branch arm, recording `--branch main --start-point main
  --start-point-reset` — equivalent to forking main from itself.
  Treat it like a `push` event so a manual run on main updates the
  baseline directly. Matches the pacquet workflow's branch policy.
- `bench.sh`: emit a stderr warning when `jq` is missing instead of
  silently skipping `bencher-results.json`. Keeps behaviour optional
  for local users but makes the skip discoverable.

* bench: rename scenarios with explicit state axes

Replaces the hyperfine-leaking names (`clean-install`, `frozen-lockfile`,
`peek`, `gvs-warm`, …) with a consistent grid that spells out every
state the benchmark depends on:

- "Fresh" — node_modules wiped at start (future variants will start
  with a populated node_modules).
- "Install" vs "Restore" vs "Add new dep" — the work being measured.
- "hot/cold cache + hot/cold store" — both pnpm directories,
  spelled out separately because they're distinct on disk.
- "isolated linker" — nodeLinker mode (future variants will cover
  `hoisted` and `pnp`).

The slugs map directly from the clap-derived kebab-case names, so
`--scenario=fresh-restore-cold-cache-cold-store-isolated` is the new
CLI surface. Updates land across the Rust orchestrator
(`BenchmarkScenario`), `benchmarks/bench.sh`, the pacquet workflow,
`benchmarks/README.md`, and `pacquet/CONTRIBUTING.md` so the names
agree end-to-end.

Adds a justified `#[allow(clippy::enum_variant_names)]` on the enum
because every variant currently shares the `Fresh` prefix; the lint
will stop firing once `Filled*`/`Resynced*` counterparts land.

Bencher's stored history for the old benchmark names will become
orphaned and can be archived in the UI.

* bench: linker-first slug shape with dot-separated axes

Reshapes the scenario identifiers so the linker mode is the leading
group: `<linker>.<action>.<cache state>.<store state>`. Dots separate
the four axes the bench varies, and `isolated-linker.*` /
`gvs-linker.*` sort together in any dashboard that groups by prefix.
Future buckets (`hoisted-linker.*`, `pnp-linker.*`) will slot in
without disturbing the existing names.

GVS is its own top-level bucket rather than a sub-variant of
isolated — its perf profile differs enough to chart separately.

Renames:

- `clean-install` → `isolated-linker.fresh-install.cold-cache.cold-store`
- `full-resolution` → `isolated-linker.fresh-install.hot-cache.hot-store`
- `frozen-lockfile` → `isolated-linker.fresh-restore.cold-cache.cold-store`
- `frozen-lockfile-hot-cache` → `isolated-linker.fresh-restore.hot-cache.hot-store`
- `peek` → `isolated-linker.fresh-add-dep.hot-cache.hot-store`
- `gvs-warm` → `gvs-linker.fresh-restore.hot-cache.hot-store`

Each Rust variant now carries `#[value(name = "…")]` so clap accepts
the dotted CLI form (`--scenario=isolated-linker.fresh-install.cold-cache.cold-store`).

Display labels follow the slug structure: `Isolated linker: fresh
install, cold cache + cold store` and `GVS linker: fresh restore,
hot cache + hot store`.

The `#[allow(clippy::enum_variant_names)]` is renewed; 5 of 6 variants
share the `Isolated` prefix today. Once `Hoisted*` / `Pnp*` buckets
land the lint will stop firing on its own.

* style: apply rustfmt after scenario rename

The longer match-arm pattern produced by the linker-first rename
exceeded the rustfmt width budget. Auto-format breaks the
`&["install", "--frozen-lockfile"]` body onto its own line so the
arm stays within the limit.
2026-05-23 15:19:49 +02:00
Zoltan Kochan 8c68c20211 fix(pacquet): drop synthetic transitive-peer entries from the importer lockfile (#11877)
Aligns `dependencies_graph_to_lockfile` with pnpm's
`addDirectDependenciesToLockfile`, which only writes manifest-declared
aliases into `importers["."].dependencies` / `importers["."].specifiers`.
The earlier synthetic-specifier branch (added in #11867 to keep the
top-level symlinks the old recursive walker happened to produce) wrote
the resolver's transitive auto-installed peers into the importer block
with the resolved version as their specifier, so `pnpm-lock.yaml`
ended up listing entries the on-disk `package.json` never declared.

`satisfies_package_manifest` then read those entries on the next install,
flagged 17 "removed" specifiers against the alotta-files fixture, and
the auto-frozen fast path (`preferFrozenLockfile: true`) fell through
to the full fresh-resolve pipeline — turning the bench's
`withWarmCacheAndLockfile` / `withWarmModulesAndLockfile` / `repeatInstall`
scenarios from sub-second no-ops into ~4.5 s re-resolutions in
pacquet@0.2.5.

Verified against pnpm itself (`pnpm install react-dom@18.2.0` →
lockfile lists only `react-dom` in `importers["."].dependencies`,
no top-level `react` symlink). Pacquet now produces the same shape:
transitive auto-installed peers live in `snapshots:` / `packages:`
only, consumers reach them through their parent's slot's
`node_modules`, and the freshness gate stays clean across installs.

Local reproduction on the alotta-files fixture:
- importer.dependencies count: 126 → 109 (matches the manifest exactly)
- `pacquet install --frozen-lockfile`: was failing with
  "17 dependencies were removed", now succeeds
- `repeatInstall`: ~22 s → ~0.5 s

Test updates:
- `auto_install_peers_hoists_missing_peers_at_importer` keeps the
  `.pnpm/` virtual-store assertions (the actual hoist-loop contract)
  and drops the top-level `node_modules/<peer>` symlink loop, which
  was asserting behavior pnpm itself doesn't produce.
- `should_install_dependencies` snapshot drops the three
  `node_modules/@pnpm/{x,y,z}` entries for the same reason — those
  are `@pnpm/xyz`'s transitive peers and never reach the importer's
  `node_modules` under pnpm.

Refs #11867.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-23 14:21:10 +02:00
Zoltan Kochan e7820bf650 perf(pacquet): cache per-wanted resolves in resolve_dependency_tree (#11874)
* perf(pacquet): cache per-wanted resolves in resolve_dependency_tree

Memoise `resolver.resolve()` and `extract_children()` on the tree-walk
context. The first visit for a given `(alias, bare_specifier, optional)`
wanted dep runs the resolver chain; later visits clone the cached
`Arc<ResolveResult>` and skip `pick_package`'s cache-key formatting,
HashMap probing, and semver matching. The first visit for a given
`pkgIdWithPatchHash` walks the manifest for child specs; later visits
clone the cached `Arc<Vec<ChildSpec>>` and skip the JSON traversal.

Per-occurrence `NodeId`s and the cycle-break gate are unchanged, so
peer-suffix variants for non-leaf packages stay intact. Mirrors pnpm's
`resolveDependencies.ts:1584` `isNew` gate at the wanted-dep edge,
which is the layer pacquet's recursion shape can short-circuit
without restructuring the tree builder.

Refs #11869.

* docs(pacquet): unlink cross-crate pick_package references

`pick_package` lives in `resolving-npm-resolver`, not in the
deps-resolver crate, so the `[`pick_package`]` intra-doc links broke
`cargo doc --document-private-items` under `-D warnings`. Drop the
link form and keep the bare backticks.
2026-05-23 12:53:46 +02:00
6ae4b5d5ba chore(lint): more clippy rules (#11855)
* chore(lint): more clippy rules

* fix: address clippy needless_collect warnings

* fix: silence clippy or_fun_call warnings

* fix: remove unnecessary clone in link_bins tests

* chore(lint): reorder clippy warnings for clarity

* fix: address clippy needless_collect warning in run_workers function

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix cargo fmt

* fix: address review feedback on needless_collect lint suppressions

Switch `#[allow]` to `#[expect]` and revert the `.any()` rewrite in
`resolve_importer/tests.rs` back to the more readable `Vec<&str>` +
`.contains()` form, suppressing the lint locally with a reason.

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-23 12:20:58 +02:00
Zoltan Kochan d4a2b0364c chore: update dependencies (#11802) 2026-05-23 11:47:41 +02:00
Zoltan Kochan c5a1d0842a perf(package-manager): route fresh-lockfile install through CreateVirtualStore (#11867)
Replace `install_with_fresh_lockfile`'s recursive per-package
`install_subtree` walk with the phased warm/cold-batch pipeline
`install_frozen_lockfile` already uses (`CreateVirtualStore` +
`SymlinkDirectDependencies` + `LinkVirtualStoreBins`). The recursive
shape pinned one tokio worker per in-flight package on its own
rayon `par_iter` for the per-package link step; replacing it with a
single phased `par_iter` over every snapshot closes most of the
gap to pnpm on the no-lockfile install paths.

5-run hyperfine against the verdaccio mock, 10-core M-series Mac:

| scenario                  | before |  after | pnpm  | who wins |
|---------------------------|------:|------:|------:|----------|
| clean-install, cold cache | 25.5s | 13.7s | 24.6s | **pacquet 1.79×** |
| full-resolution, warm     | 22.1s | ~10s  |  9.4s | pnpm 1.07× (was 1.94×) |
| frozen, cold cache        | 21.1s | 19.6s | 21.7s | pacquet (unchanged) |
| frozen, warm cache        |  7.5s |  7.6s |  9.6s | pacquet (unchanged) |

The `install_with_fresh_lockfile` path already builds the v9
lockfile shape from the resolved graph (`built_lockfile`) and
constructs `VirtualStoreLayout::new` — `CreateVirtualStore` consumes
exactly that shape, so the refactor is mostly plumbing. `install_subtree`
and `InstallCtx` go away with it.

`dependencies_graph_to_lockfile` is also updated so auto-installed
peers (resolved under `autoInstallPeers: true` but not present in the
manifest) make it into the importer's `dependencies` map with a
synthetic specifier matching the resolved version. The recursive
walker surfaced these as top-level symlinks by reading
`direct_dependencies_by_alias` directly; `SymlinkDirectDependencies`
reads `built_lockfile.importers["."]`, so without recording the
hoisted peers there, the
`auto_install_peers_hoists_missing_peers_at_importer` regression
test fails. The `specifiers:` map skips the synthetic entry — only
manifest-authored specifiers belong there.

Refs #11866, #11857.
2026-05-23 11:31:21 +02:00
Zoltan Kochan 976504fd04 perf(pacquet): close the clean-install gap to pnpm CLI (#11856)
Cuts pacquet's clean-install and full-resolution wallclock by roughly 3-4× on Linux (and roughly 2× on macOS), and brings user-CPU below pnpm's, primarily by:

- pipelining tarball fetches with resolution (re-application of a prior draft);
- caching disk-loaded packuments in `pick_package`'s in-memory `meta_cache` so repeated resolves of the same `(registry, name)` don't re-pay `spawn_blocking` + multi-MB `serde_json::from_str` (biggest single win);
- de-duplicating the resolve-time tarball prefetch via an atomic `DashSet` claim (one spawn per unique URL, not per `(parent, child)` edge), routing the prefetch download through `SilentReporter` so it doesn't fire `pnpm:progress` ahead of `resolved`, and dropping a write-lock that was used purely to read a `CacheValue` variant;
- shaving redundant pre-flight `stat` syscalls in `link_file` (~130k saved per alotta-files install) and `symlink_package` (~3-5k saved), and simplifying the EEXIST recovery to a single `return Ok(())` matching pnpm's `linkOrCopy`.

## Bench (alotta-files fixture, verdaccio mock)

Linux CI numbers from the auto-bench on this PR:

| Scenario                       | pacquet@main | pacquet@HEAD | pnpm    | vs pnpm before → after |
|--------------------------------|-------------:|-------------:|--------:|-----------------------:|
| Clean Install                  | 19.7 s       | **5.91 s**   | 4.60 s  | 4.29× → **1.29×** slower |
| Full Resolution                | 20.4 s       | **5.12 s**   | 3.34 s  | 6.09× → **1.53×** slower |
| Frozen Lockfile                | 2.02 s       | 2.17 s       | 3.57 s  | already 1.77× **faster** (unchanged) |
| Frozen Lockfile (Hot Cache)    | 756 ms       | 752 ms       | 2125 ms | already 2.83× **faster** (unchanged) |

User CPU on clean-install drops from 23.9 s (pacquet@main) to 6.19 s (pacquet@HEAD), comparable to pnpm's 6.48 s — meaning the resolver no longer over-uses CPU. The frozen-lockfile path doesn't touch the resolver, so it's unchanged (the small shift is within the stddev bands).

Local macOS sees a smaller win (Clean Install ~1.5× pnpm) — `sys` time is dominated by APFS metadata-journal contention that bites pacquet harder than pnpm because of pacquet's default `2 × available_parallelism` rayon pool. Tracked separately as #11851 / #11857.

## Commits

1. `perf(pacquet): pipeline tarball fetches with resolution` — wraps the resolver chain in a `PrefetchingResolver` that `tokio::spawn`s a `DownloadTarballToStore::run_with_mem_cache` after every successful resolve. Mirrors pnpm's `packageRequester.requestPackage` shape (the `fetching` promise is already running by the time the resolver returns). Re-applies an earlier draft (commit `f375c9161e` on the `pacquet-perf` branch). Also tightens the bench harness's git-repo check to accept worktree `.git` files via a `gitdir:` content check.
2. `perf(pacquet/resolving-npm-resolver): cache disk-loaded packuments in pick_package` — the version-spec disk fast path and the `publishedBy` mtime shortcut loaded the packument mirror from disk but never populated `ctx.meta_cache`, so every later resolve of the same `(registry, name)` re-ran `spawn_blocking(load_meta)` + a multi-MB `serde_json::from_str`. Promote the disk-loaded packument into the install-scoped cache. **Biggest single win** — resolve phase 25 s → 3 s, user CPU 4× lower.
3. `perf(pacquet/tarball): dedup prefetch spawns and read-lock cache state` — multiple improvements to the prefetcher's tarball download path:
    - Atomic per-URL dedup via a `DashSet<String>` on the `PrefetchingResolver`. The previous `mem_cache.contains_key()` gate was a TOCTOU pair (two concurrent resolvers could both pass it and both `tokio::spawn`); `DashSet::insert` is the atomic claim and only the winner spawns. The `MemCache` still backstops correctness.
    - Route the prefetch's `DownloadTarballToStore::run_with_mem_cache` through `SilentReporter` so `pnpm:progress fetched` / `found_in_store` don't fire ahead of the install pass's `resolved`. The install pass's later call to `run_with_mem_cache` now emits `found_in_store` on the `Available` short-circuit, so consumers still see the documented `resolved → fetched|found_in_store → imported` triple once per URL.
    - Switch the waiter's `cache_lock.write().await` to `read().await` — the variant inspection is read-only, and the owner branch is the only writer.
4. `perf(pacquet/package-manager): drop pre-flight stats in link_file` — first half of the link_file simplification: the original two `fs::metadata` + `fs::symlink_metadata` calls per file collapsed to a single `fs::metadata` short-circuit, deferring dangling-symlink detection to the EEXIST recovery path.
5. `perf(pacquet/package-manager): drop redundant create_dir_all in symlink_package` — `force_symlink_dir` already handles a missing parent via its own `NotFound` → `create_dir_all` retry; the explicit pre-create was paying ~3-5k unneeded `stat`s on a typical install.
6. `fix(pacquet/package-manager): keep one stat in link_file, simplify EEXIST` — second half of the link_file simplification. Keeps the single `fs::metadata` pre-check (needed because `fs::copy` overwrites silently rather than surfacing EEXIST, so `Copy` callers need the no-op short-circuit) and simplifies the EEXIST recovery to a single `return Ok(())` matching pnpm's `linkOrCopy`. Drops the dangling-symlink scrub for the same reason — pnpm doesn't scrub either, and CAFS contents are content-addressed so a "stale" dangling symlink from a crashed prior install is a corruption case the install layer is not the right place to repair. Test `link_file::tests::dangling_symlink_is_replaced` → `dangling_symlink_is_preserved` locks in the new (pnpm-aligned) contract.

## Where the remaining gap lives

pacquet@HEAD user-CPU on clean-install is now **lower** than pnpm's (6.19 s vs 6.48 s on the Linux CI). The remaining ~1.3-1.5× wallclock gap is `sys` time — file-system syscalls during the link phase (verify-files-cache stats, per-file `linkat`/`clonefile`/`copy_file_range`, store-index lookups). The macOS-only multiplier on top of that is APFS metadata-journal contention from pacquet's over-subscribed rayon pool; see #11851 and the follow-up tracker #11857.
2026-05-22 16:59:28 +02:00
KhảiandClaude d4136eb6f6 chore(pacquet/lint): more clippy (#11839)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-22 06:48:50 +00:00
KhảiandClaude 815e507d67 ci(integrated-benchmark): scenarios without lockfiles (#11838)
* ci(benchmark): run all six integrated-benchmark scenarios

Wires `clean-install`, `full-resolution`, `peek`, and `gvs-warm` into
`pacquet-integrated-benchmark.yml` so per-PR runs cover the same scenario
set the manual `benchmark.yml` workflow already exercises via
`benchmarks/bench.sh`. Requested for #11837, where the perf delta affects
the resolution-bound scenarios (`firstInstall`, `withWarmCache`,
`withWarmModules`, `updatedDependencies`) that the prior two-scenario set
did not measure.

Each scenario gets its own step with a 10 min hyperfine timeout (same
rationale as the existing steps) and writes per-scenario report copies
that the summary step concatenates into `SUMMARY.md`.

* ci(benchmark): drop peek and gvs-warm scenarios

Keep only the two new no-lockfile scenarios (`clean-install`,
`full-resolution`) on top of the existing `frozen-lockfile` and
`frozen-lockfile-hot-cache`. #11837's perf change is in the
fresh-lockfile install path, which only runs when resolution runs — i.e.,
exactly the no-lockfile scenarios. `peek` mutates an existing lockfile
and `gvs-warm` is a frozen-lockfile variant; neither exercises the
affected path, and including them only costs per-PR CI wall time.

* fix(bench): pin packages: ['.'] in synthesized pnpm-workspace.yaml

The integrated-benchmark clones each pacquet revision's source tree into
`<bench_dir>/pacquet/`, which on the pnpm/pnpm monorepo includes upstream
test fixtures like
`workspace/project-manifest-reader/__fixtures__/invalid-package-json/package.json`
— intentionally malformed JSON used to exercise pnpm's manifest reader.

Without a `packages:` field, both pnpm's `findPackages.ts:28` and
pacquet's `crates/workspace/src/projects.rs:128` default to `[".", "**"]`,
so the fresh-resolve install path's `find_workspace_projects` walk
descends into the cloned source tree and trips on the bad fixture:

  Error: pacquet_package_manifest::serialization_error
    × installing dependencies
    ╰─▶ expected `,` or `}` at line 3 column 3

The walk only runs on the fresh-lockfile branch (`install.rs:628-630`),
which is why frozen-lockfile and frozen-lockfile-hot-cache stay green
while clean-install and full-resolution fail every time.

Pin `packages: ['.']` in the synthesized manifest so enumeration stays
at the workspace root. The benchmark's installs are single-project,
so this doesn't narrow anything the install actually needed to see.
Fixtures supplied via `--fixture-dir` that already declare `packages:`
keep their own value.

* ci(benchmark): bump no-lockfile scenarios to 20 min

Clean-install and full-resolution go through pacquet's fresh-resolve
install path, which is currently ~3-5x slower than pnpm on the
`alotta-files` fixture (pnpm/pnpm#11832). Hyperfine's default 1 warmup
+ 10 timed runs across three benchmark targets (pacquet@HEAD,
pacquet@main, system pnpm) projects to ~13 min wallclock for these
two scenarios, putting the previous 10 min cap right on the edge.
Doubling to 20 min keeps the per-step timeout meaningful as a stuck-
install detector without losing CI time when the bench is healthy.

The frozen-lockfile steps stay at 10 min — they don't traverse the
slower fresh-resolve path.

* fix(bench): drop --no-frozen-lockfile from full-resolution scenario

Pacquet doesn't expose `--no-frozen-lockfile` (only `--frozen-lockfile`,
`--prefer-frozen-lockfile`, and `--no-prefer-frozen-lockfile`). Passing
it makes clap reject the install:

  error: unexpected argument '--no-frozen-lockfile' found
    tip: a similar argument exists: '--frozen-lockfile'

The flag was redundant for this scenario anyway: full-resolution starts
every iteration with no lockfile on disk (init() skips the lockfile when
`lockfile_enabled()` is false; cleanup removes it; `lockfile=false` in
the synthesized npmrc/workspace prevents writing one). With no lockfile
present the frozen path is unreachable regardless of the flag, so both
tools take fresh resolution by definition. Fold full-resolution into
clean-install's bare `install` arm.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-22 08:03:51 +02:00
Zoltan Kochan 54ff453268 perf(pacquet): collapse leaf-package occurrences in the dependencies tree (#11847)
Mirrors pnpm's `pkgIsLeaf` reuse: a package with no `dependencies`,
`optionalDependencies`, `peerDependencies`, or `peerDependenciesMeta`
collapses every parent reference onto one tree node keyed by the
package id, instead of allocating a fresh `NodeId` per occurrence.
Peer resolution and every downstream `HashMap<NodeId, _>` see the
reduced node set.

Closes #11844.
2026-05-22 08:03:01 +02:00
David BarrattandClaude Opus 4.7 3422cecfd3 fix(installing.deps-resolver): deterministically order cyclic peer suffixes (#11826)
* fix(installing.deps-resolver): deterministically order cyclic peer suffixes (#8155)

`resolveDependencies` was pushing onto `pkgAddresses`, `postponedResolutionsQueue`,
and `postponedPeersResolutionQueue` from inside `Promise.all`-spawned callbacks,
so the order of items in those arrays reflected completion timing rather than
the order of `extendedWantedDeps`. That ordering then flowed downstream into
`resolvePeers` and the cyclic-peer suffix assignment, so two packages with
transitive peer dependencies on each other (e.g. `@aws-sdk/client-sts` and
`@aws-sdk/client-sso-oidc`) flipped between two equally-valid lockfile forms
across consecutive installs.

The fix awaits `Promise.all` to a temporary array and drains it with `for…of`
so the per-edge results land in input order. This matches the existing pattern
200 lines earlier in `resolveDependenciesOfImporters`.

End-to-end repro from the issue (`pnpm add @aws-sdk/client-s3@3.588.0` then
loop `pnpm dedupe --check`): 33/50 failures without the fix → 0/100 with it.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(installing.deps-installer): replace all slashes in mock metadata path

Addresses CodeQL incomplete-string-escaping finding: `replace('/', '%2F')`
only swaps the first occurrence. Scoped names in this test only have one
slash so the behavior is unchanged, but switching to `replaceAll` clears
the warning and is more defensible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(installing.deps-installer): assert raw snapshot key order

Removed the .sort() applied to the lockfile snapshot keys in the cyclic
peer determinism test so the comparison reflects the actual order
emitted by the lockfile writer. The deterministic ordering guaranteed
by 7577d47 makes the sorted view and the raw view identical today;
dropping the sort lets the test fail on any future regression that
keeps the key set stable but shuffles the order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(installing.deps-installer): drop stray manifest from MutatedProject literal

MutatedProject does not carry a manifest field; it is conveyed via
allProjects in MutateModulesOptions. Passing it inside the install
project literal triggered TS2353 against the InstallDepsMutation shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(installing.deps-installer): rename metas to metaByName

Clearer name for the map keyed by package name, and avoids tripping
cspell on the abbreviation "metas".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: retrigger CI

Ubuntu Node.js 22.13.0 hit a transient 404 from Verdaccio's
proxy-to-npm while resolving a transitive peer of
@medusajs/medusa-js@6.1.7 in the pre-existing
"install should not hang on circular peer dependencies"
test (installing/deps-installer/test/install/misc.ts:1247).
Ubuntu Node 24 and Node 26 ran the same code green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:20:01 +02:00
Zoltan Kochan 5353fcbf01 perf(pacquet): close the warm-cache resolve gap to pnpm CLI (#11837)
Closes #11832.

On the `alotta-files` benchmark (1362 nodes, warm cache, GVS on), pacquet was 3-5× behind the TypeScript pnpm CLI whenever resolution ran (`firstInstall`, `withWarmCache`, `withWarmModules`, `updatedDependencies`). Wall-clock dropped from ~11.83s to ~5.03s on this branch; pnpm sits at ~4.16s, and the remaining gap is concentrated in the resolver's per-node `pick_package` walk (3.1s of the 5.03s — see #11843 for the `peekManifestFromStore` follow-up that would close it).

The branch is a series of small wins rather than one big rewrite. The original `PrefetchingResolver` (commit f375c916) was replaced by a batched store-index prefetch (461a4c02) — same throughput, far less plumbing.

## What's in this PR

### Resolve-phase

- **Packument fetch dedup** (386a90b5) — `PackumentFetchLocker` (per-cache-key `DashMap<String, Arc<Semaphore>>`) so concurrent picks of the same `(registry, name)` coalesce into one HTTP GET. Mirrors pnpm's `runLimited(pkgMirror, …)` in `pickPackage.ts`. Pacquet was firing N parallel GETs for the same packument per cluster of cross-referencing deps; now it's one.
- **Conditional GET on upgrade fetch** (58f49c90) — forward `etag` / `modified` so the registry can answer `304 Not Modified` on the abbreviated-to-full re-fetch path.
- **Off-tokio mirror disk reads** (6cb50b4f) — the packument cache's mirror read moves to `spawn_blocking` instead of running on the tokio worker.
- **Picked-manifest serialisation dedup** (387b8721) — `PickedManifestCache` `Arc<DashMap<String, Arc<Value>>>` so duplicate picks of the same `name@version` reuse the already-serialised `Arc<Value>` instead of re-running `serde_json::to_value`.
- **Arc-shared resolver outputs** (743c718f, 53e3cde6, 5d6a4207) — `Package`, `ResolveResult.manifest`, and `ResolveResult` itself are now shared via `Arc` so the tree walk's per-occurrence clones become refcount bumps.
- **`std::sync::Mutex` on `TreeCtx`** (a7c94a90) — the per-package dedupe gate is a short `HashMap` insert with no `await` inside, so a sync mutex is the right tool. Tokio's async mutex was paying per-acquire overhead once per visit per ctx field on the resolve hot path.

### Install-phase

- **Batched store-index prefetch** (461a4c02) — one `SELECT … WHERE key IN (…)` against `index.db` at install start, rayon-parallel verify, drops the SQLite mutex before any fs work. Replaces the per-snapshot `spawn_blocking` fan-out that was serialising on `Arc<Mutex<StoreIndex>>` and queueing in tokio's blocking pool.
- **Single `pnpm:progress` per URL** (e54208e1) — `run_with_mem_cache` was emitting `fetched` twice when the in-memory cache hit; mirrors pnpm's `packageRequester` shape where the emit fires exactly once.
- **Retain prefetched manifests for bin linking** (b0bf5970) — the fresh-install bin linker now drives `LinkVirtualStoreBins` with the prefetched bundled-manifests map (and built lockfile snapshots), so per-child `package.json` disk reads on warm hits are gone. Skips the `read_dir` enumeration too. Updated snapshots reflect the now-present `<slot>/node_modules/<pkg>/node_modules/.bin/<pkg>` self-shim the lockfile-driven path writes per pnpm's `linkBinsOfDependencies`.
- **Skip per-snapshot deep clone on warm prefetch** (3f9c1bb5) — `run_with_mem_cache` returns the prefetched `Arc<HashMap>` straight through instead of going via `run_without_mem_cache`'s deep-clone path + redundant `Arc::new`. At 1k+ snapshots that's one per-file map allocation and one `Arc::new` saved per snapshot.

### Correctness

- **Registry-scoped `PickedManifestCache` key** (57c3094e) — the shared cache key was `{name}@{version}` only; two registries (default + JSR + named-registry) serving different artifacts under the same `name@version` (private + public collisions, forks) would hand one resolver the other's manifest. Now keyed `{registry}\x00{name}@{version}`, matching `PackageMetaCache`'s shape. Regression test included.

### Diagnostics

- **Per-phase timing logs** (57864d2c) — `pacquet::install::phase` `tracing` events with `elapsed_ms` for `resolve_importer`, `prefetch_cas_paths`, `build_fresh_lockfile`, `virtual_store_layout_new`, `install_subtree`. Made the profiling for this branch tractable and stays in for future work (the same per-phase trace is what motivated #11843).

### Review cleanup

- **Refactor + comment fixes** (e7b3e6ca) — `build_resolve_result` now takes a `BuildResolveResult` struct instead of 9 positional args (`#[allow(clippy::too_many_arguments)]` gone); resolver-side `Arc` bindings are dropped explicitly before the install pass so the packument cache actually frees; the `resolve_dependency_tree` doc comment was wrong about "skipping the recursion" on dedupe hits.

### Doc + Dylint fixes

- **CI compliance** (0343a472) — broken doc links resolved; single-letter generics, `Arc.clone()` direct, unicode ellipsis in doc comments fixed for Dylint.

## Scope

- **Fresh-lockfile install path only.** The frozen-lockfile path already had the batched store-index prefetch; nothing else here changes its behaviour.
- **No user-visible behavior change** — lockfile format, error codes, CLI surface, `MemCache` semantics unchanged. The cache-key bug fix doesn't change the on-disk lockfile; it only prevents an in-memory mix-up that would otherwise produce a wrong `ResolveResult.manifest` field.

## Follow-ups

- **#11843** — port pnpm's `peekManifestFromStore` fast path. The store-index row carries the bundled `package.json` (name, version, deps, bin, engines, etc.) but no publish-time, so the fast path is safe only when no `published_by` / `minimumReleaseAge` policy is in effect, no `--update`, and the wanted lockfile pins a tarball+integrity. With ~95% of nodes short-circuiting, the `resolve_importer` phase (currently 3.1s on warm cache) drops dramatically — this is the single biggest unimplemented win and the most likely path to parity with pnpm.

## Benchmark

Wall-clock progression on the `alotta-files` warm-cache + GVS-on scenario (this branch vs `main`):

| Stage                               | Wall    |
| ----------------------------------- | ------- |
| `main`                              | ~11.83s |
| + packument fetch dedup             | ~8.21s  |
| + batched store-index prefetch      | ~6.39s  |
| + Arc-shared resolver outputs       | ~5.67s  |
| + std Mutex + manifest serial dedup | ~5.03s  |
| pnpm CLI baseline                   | ~4.16s  |

The remaining ~0.87s gap is concentrated in `resolve_importer` and is what #11843 targets.
2026-05-22 02:15:53 +02:00
Zoltan Kochan 9eb632bfbd fix(registry): tolerate non-string entries in dependency maps (#11833)
Historical npm registry entries sometimes carry object-valued
`dependencies` / `devDependencies` / `peerDependencies` (e.g.
`deep-diff@0.1.0`'s nested `devDependencies: { vows: { version, ... } }`).
pnpm's JS path silently ignores these via later `typeof spec === 'string'`
checks; pacquet's strict serde failed the whole packument with
`invalid type: map, expected a string`, surfacing as a spurious
`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` for an unrelated version pinned
in the lockfile.

Custom deserializer drops non-string entries while keeping the string
ones, matching pnpm's tolerance. Issue #11829.
2026-05-21 21:13:06 +02:00
Khải fd564a43b9 ci(benchmark): consolidate (#11741)
Unify the two install-benchmark stacks (`benchmarks/bench.sh` and `pacquet/tasks/integrated-benchmark/`) into one shared Rust orchestrator. Scenario, fixture, workspace-manifest, install-script, and report generation live in one place so changes propagate to both stacks.

Scenario sets per workflow are unchanged: `benchmark.yml` keeps measuring the same 6 scenarios; `pacquet-integrated-benchmark.yml` keeps measuring the same 2. Both verdaccio and live-npm registry modes are preserved; neither is removed in favor of the other. All six scenarios accept both `pacquet@<rev>` and `pnpm@<rev>` targets.
2026-05-21 21:12:29 +02:00
Zoltan Kochan fecaee0b35 chore: update pnpm and add pacquet to config deps (#11765) 2026-05-21 18:45:01 +02:00
Zoltan Kochan a5a2c2482e fix(pacquet/store-dir): gate verify_file's destructive branch on cas_write_lock (#11825)
The verifier was racing in-flight writers: while `ensure_file` held
`cas_write_lock(path)` and was partway through `write_all`, a
sibling snapshot's `check_pkg_files_integrity` would stat the same
CAS path (no lock), see a partial size, and call
`remove_stale_cafs_entry(path)`. The writer's `write_all` then
continued against an orphan inode, the writer's `cas_paths` was
populated with the now-deleted path, and `link_file` later hit
ENOENT — the CI failure shape on #11816 / 0.2.2-7.

Fix (Option C): keep `verify_file`'s lock-free fast path (the
common case: file unchanged since prior install, `is_modified`
false), but acquire `cas_write_lock(path)` before any branch that
could call `remove_stale_cafs_entry`. Re-`check_file` under the
lock so a writer's full `write_all` lands before we evaluate.

Performance: the fast path adds zero overhead. The slow path —
files whose mtime is > 100 ms past the recorded `checked_at` —
takes one uncontended Mutex acquire per file, sub-microsecond
on uncontended locks. Contention only fires when a writer + a
verifier hit the same blob simultaneously; the wait is bounded
by one `write_all` and trades a millisecond-scale wait for
avoiding a network re-fetch.

The new integration test in `pacquet-store-dir/tests/` is a
deterministic reproducer: it acquires `cas_write_lock` from the
test thread (standing in for an in-flight writer), pre-seeds a
partial CAS file at the matching path, and runs the verifier in
the background. Pre-fix, the verifier unlinks the file while the
"writer" is still simulated as in-progress; post-fix, the
verifier blocks on the lock until released.

To make `cas_write_lock` reachable from the store-dir crate the
function was promoted from `fn` to `pub fn` in pacquet-fs.
2026-05-21 18:22:22 +02:00
Zoltan Kochan f9a0abe02d test(pacquet/fs): port upstream multi-process CAS stress tests (#11823)
Adds the three cross-process scenarios upstream pnpm covers in
store/cafs/test/writeBufferToCafs.test.ts but pacquet only covered
intra-process (one 32-thread test): N workers racing on the same
target with a corrupt pre-seed, with a truncated pre-seed, and on a
clean target.

Pacquet's `cas_write_lock` is process-local (`OnceLock<DashMap<...>>`)
just like upstream's `locker: Map<string, number>`, so the cross-
process safety contract lives entirely in `O_CREAT | O_EXCL` +
atomic-rename. The existing 32-thread test in `ensure_file::tests`
exercises the lock; the new suite exercises the unprotected
filesystem-only path so a regression in the `verify_or_rewrite` +
`write_atomic` recovery would surface as a test failure instead of a
production install failing to import a CAS file at link time.

Approach:

- New `[[bin]]` `cafs_stress_worker` under `pacquet-fs/src/bin/`.
  Reads a content fixture from argv[1] and a target path from
  argv[2], calls `ensure_file`, exits 0/1. Tiny and test-only;
  `pacquet-fs` is `publish = false` so an extra bin target is free.
- New integration test `pacquet-fs/tests/ensure_file_stress.rs`.
  Uses `env!("CARGO_BIN_EXE_cafs_stress_worker")` to find the bin
  Cargo builds alongside the test, then spawns 8 instances per
  scenario via `std::process::Command`.
- Each scenario then asserts every worker exited 0 and the final
  on-disk content sha-512-matches the expected payload.

Each recovery test was verified to catch a regression: temporarily
bypassing `verify_or_rewrite` flips both recovery tests red while
the clean-target test (which doesn't pre-seed anything) stays
green, matching upstream's coverage shape.
2026-05-21 17:53:34 +02:00
Zoltan Kochan 1386b5987d feat(pacquet): honor preferFrozenLockfile in the install dispatch (#11824)
`pacquet install` (no flag) didn't consult `preferFrozenLockfile`. A
fresh lockfile got re-resolved from the registry instead of taking the
cheap frozen path, and a stale lockfile was silently overwritten
without seeding the resolver from the existing pins. Closes pnpm/pnpm#11815.

The install dispatch now has four ordered states:

1. `--frozen-lockfile` flag → frozen path (lockfile required, freshness
   check fatal).
2. No flag + lockfile present + effective `preferFrozenLockfile == true`
   + freshness check passes → frozen path (same code as state 1).
3. No flag + lockfile present + opt-out or stale → fresh-resolve, seeded
   from the existing lockfile's snapshots so unrelated pins survive the
   rewrite (mirrors upstream's `update: false` resolver mode).
4. No lockfile → fresh-resolve with no seed.

`check_lockfile_freshness` is the shared helper: it runs
`pnpm.overrides` parsing, `check_lockfile_settings`, the overrides-aware
manifest re-apply, and `satisfies_package_manifest`. State 1 surfaces
its `Err` as `InstallError`; state 2 treats a stale-lockfile `Err` as
fall-through and surfaces `InvalidOverrides` as fatal.

CLI exposes `--prefer-frozen-lockfile` / `--no-prefer-frozen-lockfile`
mirroring pnpm so users can override per invocation; `pacquet add` opts
out of the fast path explicitly since the manifest is necessarily
stale by the time the install dispatch runs.
2026-05-21 17:53:03 +02:00
Zoltan Kochan 5881b57115 feat(pacquet): honor enableGlobalVirtualStore in the fresh-resolve install path (#11819)
`pacquet install` (no flag, fresh project) was hardcoded to
`VirtualStoreLayout::legacy`, so it materialized packages under the
project-local `node_modules/.pnpm/` even when `enableGlobalVirtualStore:
true` was configured. Closes pnpm/pnpm#11814.

A new `build_lockfile_view_from_resolver_graph` adapter converts the
resolver's `DependenciesGraph` into the `snapshots:` / `packages:`
shape `VirtualStoreLayout::new` already consumes, so all hashing,
slot-dir, and bin-linker code is shared with the frozen-lockfile path.
The without-lockfile flow now also calls `register_project` against
the shared store when GVS is on, mirroring the frozen-lockfile branch.

Side effect: aligns the without-lockfile path's per-package save
directory with the peer-suffixed slot the children-recursion was
already using, so peer-suffixed snapshots no longer split between two
unreachable slot directories.
2026-05-21 17:10:35 +02:00
Zoltan Kochan 8695496f58 feat(pacquet): write pnpm-lock.yaml and <vsd>/lock.yaml on fresh install (#11816)
* feat(pacquet): write pnpm-lock.yaml on fresh install

Adds a `dependencies_graph_to_lockfile` adapter that converts the
resolver's `DependenciesGraph` into a v9 `Lockfile`, hooks it into
the install path so a fresh `pacquet install` produces a wanted
lockfile, renames `InstallWithoutLockfile` to `InstallWithFreshLockfile`
to match the new behavior, drops the `UnsupportedLockfileMode` branch
from the dispatch, and flips the `config.lockfile` default from `false`
to `true` to match pnpm.

Closes pnpm/pnpm#11813.

* chore(pacquet): fix rustdoc + rustfmt CI failures

- Drop the broken `[ResolvedPackage.optional]` intra-doc link (no such
  item in scope; the reference is upstream's, and the prose is enough
  without the link).
- Disambiguate two `dependencies_graph_to_lockfile` intra-doc links to
  the function form so rustdoc doesn't error on the function/module
  collision.
- Flatten an `assert!(prod.contains_key(...))` so rustfmt + dylint
  agree on the body (rustfmt wanted the call wrapped, dylint wanted a
  trailing comma; extracting the key into a local resolves both).

* feat(pacquet): write <virtual_store_dir>/lock.yaml in the fresh path

After a fresh install, also persist the current-lockfile alongside
`pnpm-lock.yaml` so the next install can diff each snapshot against it
and skip the unchanged slots — the same optimization the frozen path
already enables.

`InstallWithFreshLockfile::run` now returns an
`InstallWithFreshLockfileResult` that surfaces the freshly-built
`Lockfile` to the caller. `install.rs` saves it as
`<virtual_store_dir>/lock.yaml` after `.modules.yaml` succeeds, mirroring
the frozen path's safety property: a manifest-write failure can't leave
a current-lockfile pointing at incomplete install state.

The wanted lockfile and the current lockfile describe the same resolved
graph here — the resolver only walked what the install requested, so
no `filter_lockfile_for_current` step is needed. Both writes are gated
on `config.lockfile`, matching upstream pnpm's `useLockfile` opt-out.

* feat(pacquet): propagate optional flag from ResolvedPackage to SnapshotEntry

`SnapshotEntry.optional` was hard-coded to `false`, so every snapshot
looked "non-optional" and `BuildModules` would treat any build failure
as fatal even for packages reachable only via `optionalDependencies`.

Port upstream pnpm's `ResolvedPackage.optional` propagation:

1. `ResolvedPackage` (the dedup envelope) gains an `optional: bool`
   field. The walker seeds it from `wanted.optional || parent.optional`
   on the first visit and AND-folds it with `current_is_optional` on
   every subsequent visit, so a single non-optional path flips it back
   to `false` and keeps it there. Mirrors
   resolveDependencies.ts:1625-1630.
2. `extract_children` now emits a per-child `is_optional` flag — `true`
   when the entry came from the package's `optionalDependencies` map.
3. `resolve_dependency_tree` and `resolve_importer` look up the
   importer's `optionalDependencies` set and tag each direct dep, then
   propagate the flag down the recursion.
4. `DependenciesGraphNode` carries the resolved package's `optional`
   field so peer-variants of the same `pkgIdWithPatchHash` share it.
5. The lockfile adapter writes `SnapshotEntry.optional = node.optional`
   instead of hard-coding `false`.

Tests:
- 4 unit tests on the resolver (direct optional seed, transitive
  inheritance, AND-fold when a non-optional path exists, transitive
  via a parent's `optionalDependencies` edge).
- 1 unit test on the adapter (`SnapshotEntry.optional` round-trips).
- 1 integration test through `Install` asserting a top-level
  `optionalDependencies` entry produces `optional: true` in the
  written `pnpm-lock.yaml`.

Each test was verified to catch a regression by temporarily breaking
the implementation before re-checking it green.

* fix(pacquet): fail fast when fresh install gets node_linker: hoisted or --no-runtime

The fresh-lockfile dispatch silently dropped `skip_runtimes` and
`node_linker` — neither was forwarded into `InstallWithFreshLockfile`,
so a fresh `pacquet install` with `--node-linker=hoisted` produced an
isolated `node_modules` layout, and `--no-runtime` materialized runtime
archives anyway.

Pacquet's hoist pass and runtime-snapshot filter both run only against
a loaded lockfile (frozen-lockfile path); honoring the flags on a
fresh install needs upstream's per-snapshot filter and a hoist pass
over the freshly-built graph. Both ports are out of scope for this
PR.

Refuse the unsupported combinations up front instead, before any
reporter event fires or any state file is written, so a follow-up
retry under `--frozen-lockfile` against an existing lockfile lands on
the supported path. Adds `UnsupportedFreshInstallNodeLinker` and
`UnsupportedFreshInstallSkipRuntimes` error variants plus integration
tests asserting both that the error fires and that no `pnpm-lock.yaml`,
`lock.yaml`, or `.modules.yaml` ends up on disk.

* chore(pacquet): broaden fresh-install-flag error messages to non-frozen installs

The `UnsupportedFreshInstall*` errors fire on any `!frozen_lockfile`
run, not just first installs — pacquet doesn't honor an existing
lockfile without `--frozen-lockfile` yet (stale-lockfile rewrite is a
follow-up). So a re-install with a pinned `pnpm-lock.yaml` but no
`--frozen-lockfile` would hit the same gate, and the "on a fresh
install yet" wording pointed users at the wrong condition.

Reword the display strings to "without --frozen-lockfile yet" so the
prompt matches the actual gate. Variant names and diagnostic codes
stay put — the next round will rename them along with the
fresh-install-path semantics.

Surfaced by coderabbitai review on #11816.
2026-05-21 16:47:58 +02:00
Zoltan Kochan 22d6742960 fix(pacquet): resolve catalog: in pnpm.overrides before freshness check (#11820)
The frozen-lockfile freshness check compared the lockfile's overrides
map (with `catalog:` already expanded by pnpm) against the raw config
map (still containing `catalog:` strings), so every catalog-backed
override surfaced as `ERR_PNPM_OUTDATED_LOCKFILE` on every install.

Mirror pnpm's `parseOverrides(overrides, catalogs)` →
`createOverridesMapFromParsed` pipeline: thread `&Catalogs` through
`parse_overrides[_iter]`, resolve each value via `resolve_from_catalog`,
and flatten the resolved entries into the map handed to
`check_lockfile_settings`.
2026-05-21 16:47:03 +02:00
Zoltan Kochan 501681044e chore(release): 11.2.2 (#11817) v11.2.2 2026-05-21 15:45:17 +02:00
Zoltan Kochan f279d77d60 fix(pacquet/cli): patch --version literal during release build (#11812)
The `pacquet --version` string is a hardcoded clap attribute in
`cli_args.rs`. It didn't get bumped for the 0.2.2 release, so the
published binary still reports 0.2.1. Bump the literal to 0.2.2 and
add a release-workflow step that rewrites the attribute from
`inputs.version` before `cross build`, so future releases stay
correct automatically. A trailing `grep -F` fails the job loudly if
the regex stops matching after a future refactor of the attribute.
2026-05-21 14:02:45 +02:00
Zoltan Kochan 881a86541b fix(installing.commands): forward pnpm install flags to pacquet (#11781)
* fix(installing.commands): forward `pnpm install` flags to pacquet

When the install engine is delegated to pacquet via configDependencies,
pnpm hard-coded the args to `install --frozen-lockfile --reporter=ndjson`
and silently dropped the user's other CLI flags. `pnpm install --no-runtime`
therefore still installed the workspace's runtime devDependency, clobbering
the Node version the surrounding tooling had set up — visible as the
`Verify Node version` failure on PR #11765 where setup-pnpm provisions
Node 24.0.0 but pacquet then materializes node 24.6.0.

Pacquet's `install` subcommand already mirrors pnpm's surface for the
common flags (`--no-runtime`, `--prod`, `--dev`, `--no-optional`,
`--node-linker`, `--offline`, `--prefer-offline`, `--cpu`/`--os`/`--libc`).
Forward the user's argv verbatim when the command is `install`/`i`;
`add`/`update`/`dedupe` still don't forward — their flag surfaces don't
line up with pacquet's `install`.

* fix(installing.commands): pass --ignore-manifest-check to pacquet

`pnpm up` / `add` / `remove` were aborting with
`pacquet_package_manager::outdated_lockfile` whenever pacquet was
declared in `configDependencies`. After resolving and writing the
updated lockfile, pnpm hands materialization off to pacquet but
hasn't yet written the post-mutation `package.json` — that write
happens after `mutateModules` returns. Pacquet's frozen-lockfile
freshness gate then saw the new lockfile paired with the
pre-mutation manifest and refused to install.

Pass pacquet's new `--ignore-manifest-check` flag (pacquet PR #11811)
on every delegation. The flag is narrow: it only skips
`satisfies_package_manifest`. Settings drift like `overrides` is
still enforced, and pnpm already re-validated the lockfile before
delegating, so re-checking the manifest here was redundant work that
only ever fired false positives on the mutate-then-materialize path.

Requires a pacquet release that ships the flag; bump
`PACQUET_VERSION` in `pnpm/test/install/pacquet.ts` once it does, or
the existing e2e tests will fail against pacquet 0.2.2-9 (which
doesn't recognize the flag and clap would reject).

Closes #11797.

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

* fix: update pacquet in tests

* fix(installing.commands): strip positionals + always-injected flags when forwarding to pacquet

`collectForwardedFlags` checked `argv[0] === 'install'` to find the
command token to strip. Any global flag the user typed before `install`
(e.g. `--config.registry=...` in the e2e test) shifted the token out
of position, so the function returned the full argv and pacquet saw
`install` twice — `error: unexpected argument 'install' found`.

Use the parsed argv that `@pnpm/cli.parse-cli-args` already produced:
`remain` lists positionals (the `install`/`i` token and nothing else
on this code path, since `isInstallCommand` is only true when no
package params are present), and `original` preserves the user's
exact tokens. Drop positionals + the flags we always inject
(`--reporter=ndjson`, `--frozen-lockfile`, `--ignore-manifest-check`)
so clap doesn't reject duplicates either.

`original` over `cooked` deliberately: nopt's `cooked` splits
`--key=value` into two tokens, which would break pacquet's
`--config.<key>=<value>` parser (it requires the `=` form).

* fix(installing.commands): make argv.cooked/remain optional on InstallCommandOptions

Widening these to required broke test fixtures elsewhere (publish/pack/
deprecate/dist-tag/deploy) that construct minimal `argv: { original }`
options for code paths that never reach pacquet. Only the pacquet
delegation actually reads `remain`, so make the two new fields optional
on the shared options type and supply a default at the runPacquet call
site. The runtime path through main.ts already populates all three.

* fix(installing.commands): strip any user-supplied --reporter when forwarding to pacquet

Pacquet's `--reporter` is a clap value option with last-value-wins
semantics, so `pnpm install --reporter=silent` (or
`--reporter silent` two-token form) reached pacquet and overrode
the `--reporter=ndjson` pnpm injects, breaking the NDJSON-to-
streamParser plumbing the default reporter depends on. The previous
filter only matched the exact `--reporter=ndjson` token.

Walk argv with a lookahead so both `--reporter=<value>` and
`--reporter <value>` are dropped without consuming an adjacent flag.

* fix(installing.commands): drop negated/value forms of always-injected flags

`collectForwardedFlags` only matched the exact positive tokens
`--frozen-lockfile` and `--ignore-manifest-check`, so a user typing
`pnpm install --no-frozen-lockfile` (or `--frozen-lockfile=false`)
forwarded the negation to pacquet, which then saw both our injected
`--frozen-lockfile` and the user's `--no-frozen-lockfile` and crashed
clap with "unexpected argument".

Match every shape the user can write the same flag in: positive,
`--no-` negated, and any `=value` form. Can't blindly strip `--no-`
either way — pacquet has flags whose literal name starts with `no-`
(`--no-runtime`, `--no-optional`); those must still forward.

The user's `--no-frozen-lockfile` intent is honored upstream — pnpm
did a fresh resolve before delegating; pacquet's role here is just
lockfile-driven materialization, which is always frozen.

* fix(installing.commands): match positionals by index, hide reporter from dropped-flags warning

`collectForwardedFlags` matched positionals via `new Set(argv.remain)`,
which strips by value: a flag value that happened to equal a
positional token (e.g. `pnpm install --node-linker install`) was
wrongly dropped from the forwarded list, costing pacquet the value
of `--node-linker`. Walk `argv.original` with a subsequence pointer
into `argv.remain` so only the actual positional indexes get skipped.

`collectDroppedFlags` still surfaced `--reporter foo` / `--reporter=foo`
in the "may not be honored" warning on `add`/`update`/`dedupe`, but
pnpm honors reporter selection itself before delegation — so the
warning was misleading. Route both helpers through the same
`isAlwaysInjected` check and consume `--reporter` and its value the
same way `collectForwardedFlags` already does.
2026-05-21 14:00:32 +02:00
Zoltan Kochan 0dd1ec445c feat(pacquet): add --ignore-manifest-check to skip frozen-lockfile manifest gate (#11811)
Surfaces a narrow CLI flag on `pacquet install` that gates only
`satisfies_package_manifest`. Settings-drift checks (`overrides`,
`ignoredOptionalDependencies`, …) still fire, and the broader
`--ignore-package-manifest` name is reserved for a future port of
pnpm's `pnpm fetch` semantics (which skip linking / hoisting /
pruning too).

Intended for the pnpm CLI's `configDependencies` delegation path
(issue #11797): pnpm resolves and writes the lockfile, then hands
materialization to pacquet but hasn't yet written the post-mutation
`package.json`. With the flag set, the freshness gate skips the
per-importer manifest check that would otherwise reject every
`pnpm up` / `add` / `remove` with `ERR_PNPM_OUTDATED_LOCKFILE`. The
matching pnpm-side change to forward the flag lands separately.

Refs #11797.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 11:17:42 +02:00
Zoltan Kochan 3a6392828c fix(pacquet/config): pick the store on the project's volume (#11804)
* fix(pacquet/config): pick the store on the project's volume

Port pnpm's `getStorePath` / `storePathRelativeToHome` cross-volume
detection to pacquet's `default_store_dir`. When no `storeDir` is
explicitly set (global config.yaml, pnpm-workspace.yaml, or
`PNPM_CONFIG_STORE_DIR`), `Config::current` now probes whether the
project root can be hardlinked into the user's pnpm home dir. If not,
it walks from the filesystem root toward the project to find the
volume mount point and falls back to `<mountpoint>/.pnpm-store` —
matching pnpm's behaviour at
https://github.com/pnpm/pnpm/blob/29a42efc3b/store/path/src/index.ts#L14-L78.

Before this fix, a workspace on a separate volume (e.g. `/Volumes/src/`
on macOS) installed into the home-volume store (`~/Library/pnpm/store`).
When the home volume is case-insensitive and the workspace volume is
case-sensitive, typescript-eslint's path-cache canonicalises against
the home store and then can't locate the same files in TypeScript's
case-sensitive program loaded from the workspace, so `eslint --fix`
fails with "TSConfig does not include this file" on every project file.

The hardlink probe goes through a new `LinkProbe` capability in
`pacquet-config::api`, threaded into `Config::current`'s `Sys` bound
so tests can pin the linkability answer without touching disk.
The production `Host` impl performs real link attempts via
`store_path::host_can_link_between_dirs`. Test fakes get an inert
`LinkProbe` impl via the `inert_link_probe!` macro added to the
`tests` module — every probe returns `false`, the algorithm walks
without finding a mount, and the SmartDefault home store survives
unchanged, so existing cascade assertions stay valid.

* fix(pacquet/config): satisfy rustdoc + Windows clippy

- Drop the redundant explicit `[LinkProbe][crate::api::LinkProbe]`
  link target and fully qualify `[StoreDir]` as
  `[pacquet_store_dir::StoreDir]` so the `Doc` job passes under
  `-D rustdoc::broken-intra-doc-links` and `-D rustdoc::redundant-explicit-links`.
- Gate the `PrefixProbe` fake, its `ALLOW_PREFIXES` static, and the
  related imports with `#[cfg(unix)]`. Every consumer is already
  `#[cfg(unix)]`, so on Windows the items were unreferenced and the
  `Lint and Test (windows-latest)` job's clippy run failed under
  `-D dead-code`.

* test(pacquet/config): serialize PrefixProbe scenarios with a scenario lock

`ALLOW_PREFIXES` was only locked for the read/write of its `Vec`, not
across "set allowlist then probe" — under nextest's default parallel
execution two scenarios could race: scenario A would set its prefixes,
scenario B would overwrite them, and A's `resolve_store_dir` would
observe B's allowlist. Add `PREFIX_PROBE_SCENARIO_LOCK` and a
`PrefixProbe::with_allow(prefixes, body)` helper that holds it across
the entire set-and-probe so scenarios serialise cleanly.

Per CodeRabbit review on pnpm/pnpm#11804.

* test(pacquet/config): rename single-letter `R` to `Output` for Perfectionist

Dylint's `perfectionist::single-letter-generic` flagged the `R`
return-type parameter on `PrefixProbe::with_allow`. Rename to
`Output`.
2026-05-21 11:08:41 +02:00
Zoltan Kochan 09b7e9d354 docs: widen pacquet parity scope to all dep-management commands (#11810)
Update the scope caveat in AGENTS.md to state that pacquet's parity
surface now covers `install`, `add`, `update`, and `remove`, rather
than `install` alone.
2026-05-21 11:04:03 +02:00
Zoltan Kochan 86bf5db996 feat(pacquet/cli): accept --config.<key>=<value> overrides (#11806)
When pnpm delegates `install` to the pacquet binary it forwards the
user's `pnpm install` flags verbatim, including pnpm's
`--config.<key>=<value>` universal syntax (handled by npm-conf upstream).
Pacquet's clap parser previously rejected those tokens with `unexpected
argument '--config.registry'`, causing the delegated install to fail.

Strip every `--config.*` token out of argv before clap parses it and
layer the values onto `Config` after `.npmrc`/yaml have been applied,
mirroring pnpm 11's `CLI > yaml > .npmrc > defaults` precedence. Only
`registry` is wired in for now (matches the immediate test gap); unknown
keys are accepted silently so a pnpm-side key pacquet hasn't ported yet
doesn't break the delegation. Malformed tokens (`--config.foo`,
`--config.=value`) are dropped on the same path so clap never sees them
either.
2026-05-21 10:40:38 +02:00
Zoltan Kochan 69f8ea8de4 feat(pacquet): port blockExoticSubdeps to reject exotic subdeps (#11792)
Rejects git/tarball/file resolutions reached transitively from the
importer when `blockExoticSubdeps` is on. Direct deps remain allowed.
Mirrors pnpm's gate at df990fdb51 (`installing/deps-resolver/src/
resolveDependencies.ts:1420-1434`) and the closed `NON_EXOTIC_RESOLVED_VIA`
set (lines 1831-1841). Default is `true`, matching v11's
`config/reader/src/index.ts:187`.
2026-05-21 08:27:17 +02:00
Zoltan Kochan 213136dec3 feat(pacquet/resolving-npm-resolver): port abbreviated metadata fast path (#11794)
* feat(pacquet/resolving-npm-resolver): port abbreviated metadata fast path

Default the resolver-time fetch to the abbreviated install-v1
packument (`application/vnd.npm.install-v1+json`) and only request
full metadata when the call explicitly needs it. Mirrors pnpm's
`fullMetadata = opts.optional || ctx.fullMetadata` derivation in
[pickPackage.ts L201](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/pickPackage.ts#L201).

- Adds `full_metadata` to the cached/non-cached fetchers and routes
  the Accept header + mirror dir (`ABBREVIATED_META_DIR` vs
  `FULL_META_DIR`) off it.
- `PickPackageContext.full_metadata` is the install-wide bias;
  `PickPackageOptions.optional` forces full per-call (pnpm#9950).
  In-memory cache key gains a `:full` suffix when full so the two
  modes can coexist without contamination.
- Ports `maybeUpgradeAbbreviatedMetaForReleaseAge`: when
  `published_by` is active, the picker ends up with an abbreviated
  packument that lacks per-version `time`, and the package's
  top-level `modified` falls past the cutoff, the orchestrator
  re-fetches full metadata so the maturity check runs on real
  timestamps. The upgraded full meta is persisted back to the
  abbreviated mirror so the next install skips the upgrade fetch.
- Verifier and resolver pass the appropriate flag explicitly
  (`true` for the verifier, `false` for the resolver and named
  registry resolver).

Tests cover the abbreviated default, the optional override, the
cache-key separation, the boundary case `modified == cutoff`, and
the upgrade trigger when `modified > cutoff`.

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

* docs(pacquet/resolving-npm-resolver): disambiguate fetch_full_metadata intra-doc link

`fetch_full_metadata` is both a function and a module, so the bare
`[fetch_full_metadata]` link triggers
`rustdoc::broken_intra_doc_links` under `--deny warnings`. Add
parentheses so it resolves to the function.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 07:17:07 +02:00
Zoltan Kochan 400b21a90f feat(pacquet): port pnpm-workspace.yaml overrides support to the install chain (#11793)
* feat(pacquet): port pnpm.overrides support to the install chain

Adds a new `pacquet-config-parse-overrides` crate (port of
`@pnpm/config.parse-overrides`), threads `overrides` through
`Config`/`WorkspaceSettings`, surfaces lockfile-side drift as
`StalenessReason::OverridesChanged` (matching upstream's
`getOutdatedLockfileSetting` overrides branch), and applies the parsed
overrides to a cloned root manifest before the frozen-lockfile
freshness check so post-override lockfile specifiers line up with the
on-disk manifest. The read-package-hook port (`VersionsOverrider`)
mirrors upstream's `createVersionsOverrider` minus the peer-arm
promotion, which is deferred until peer install lands. Catalog refs in
override values surface as `INVALID_OVERRIDES` until catalogs are
ported.

* chore(pacquet): satisfy Dylint Perfectionist lints and fix stale doc link

Renames single-letter closure / function / generic params introduced
by the overrides port to descriptive names, fixes trailing-comma
policy in test macro invocations, swaps the Windows path literal to
a raw string, and removes a stale `[`Self::root_dir`]` rustdoc link
left behind when the `root_dir` field was dropped from
`VersionsOverrider`.

* style(pacquet): apply rustfmt to install.rs overrides_map binding

* fix(pacquet/overrides): address review feedback

- `parse_overrides` doc no longer claims insertion-order behavior; it
  accurately states that `HashMap` iteration is unordered and points
  ordered-output callers at `parse_overrides_iter`.
- `WorkspaceSettings::apply_to` now collapses `overrides: {}` from a
  later layer (env overlay, repeat `apply_to`) to `None` on `Config`,
  so an explicit empty map clears an earlier non-empty assignment
  instead of silently being skipped. Adds a regression test for the
  env-overlay-clears-yaml shape.
- `sort_by_specificity` widens its comparator to a 3-way result so
  Rust's `sort_by` total-order precondition holds. The strict
  Less/Greater arms keep the sort outcome identical to upstream's
  first-match choice; the `Equal` arm covers mutually-intersecting
  ranges.
- `resolve_local_override_spec` routes the absolute-path and
  diff-paths-fallback branches through `normalize_path` too, so
  Windows `\` separators get rewritten to `/` for every `link:` /
  `file:` shape (not just the diff-paths success branch).
2026-05-21 07:16:34 +02:00
Zoltan Kochan 667e587392 feat(pacquet): attach patch hashes to resolved pkg ids (#11791)
* feat(pacquet): attach patch hashes to resolved pkg ids

Thread `patchedDependencies` into the tree walker so each matched
package's `pkgIdWithPatchHash` gains the `(patch_hash=<hash>)` suffix
upstream's `resolveDependencies.ts` produces. The peer resolver
concatenates the peer suffix onto the patched id, so the install
layer's depPath-keyed lookups land on the patched virtual-store slot
without further changes.

Surfaces `ResolvedTree::applied_patches` for the post-walk
`ERR_PNPM_UNUSED_PATCH` check, and propagates
`ERR_PNPM_PATCH_KEY_CONFLICT` from `get_patch_info` through the
resolver error surface.

* docs(pacquet): drop private-item intra-doc links

The two `pub` items pointed at `resolve_node`, which is private —
fine under `--document-private-items` but rejected when CI runs
`cargo doc` with `-D rustdoc::private-intra-doc-links`. Rephrase to
plain prose; the call site is obvious from the surrounding context.

* style(pacquet/resolving-deps-resolver): drop trailing comma in assert!

Dylint's `perfectionist::macro_trailing_comma` rejects a trailing
comma on a single-line macro invocation.
2026-05-21 07:15:38 +02:00
Zoltan Kochan 71dfccce9a feat(pacquet): port workspace: protocol resolution and publish-time rewrite (#11789)
* feat(pacquet): port workspace: protocol resolution and publish-time rewrite

Ports the `workspace:` family of bare specifiers end-to-end:

- `pacquet-workspace-spec` (new) ports `workspace/spec-parser`'s
  `WorkspaceSpec` parser + `toString`.
- `pacquet-workspace-range-resolver` (new) ports
  `workspace/range-resolver`'s `resolveWorkspaceRange` — `*`/`^`/`~`/`""`
  pick the highest version with prereleases included; other inputs
  follow standard semver range rules.
- `pacquet-resolving-npm-resolver` grows two helpers from the upstream
  npm-resolver: `workspace_pref_to_npm` (port of `workspacePrefToNpm.ts`)
  and `try_resolve_from_workspace` (port of `tryResolveFromWorkspace` +
  `tryResolveFromWorkspacePackages` + `pickMatchingLocalVersionOrNull` +
  `resolveFromLocalPackage`). `NpmResolver::resolve_impl` now intercepts
  `workspace:` specs before the npm pick, deferring `workspace:./` /
  `workspace:../` to the local resolver. Emits `link:` / `file:`
  (injected) lockfile resolutions, with the matching
  `WORKSPACE_PKG_NOT_FOUND` / `NO_MATCHING_VERSION_INSIDE_WORKSPACE`
  / `CANNOT_RESOLVE_WORKSPACE_PROTOCOL` error codes preserved.
- `pacquet-exportable-manifest` (new) ports the publish-time
  `replaceWorkspaceProtocol` and `replaceWorkspaceProtocolPeerDependency`
  helpers from `releasing/exportable-manifest`. The full
  `createExportableManifest` (catalog rewrite, jsr rewrite, pre-pack
  hooks, publishConfig overrides) lands as pacquet ports the surrounding
  commands.
- `Install::run` builds a workspace-packages map via
  `find_workspace_projects` when a `pnpm-workspace.yaml` is present and
  threads it through `ResolveOptions::workspace_packages` so the
  resolver chain can satisfy `workspace:` specs from local projects in
  the no-lockfile install path.

Test ports:
- `workspace/spec-parser/test/workspace-spec.test.ts`
- `workspace/range-resolver/test/index.test.ts`
- `resolving/npm-resolver/test/workspacePrefToNpm.test.ts`
- `releasing/exportable-manifest/test/index.test.ts` (workspace cases)
- plus new unit tests for `try_resolve_from_workspace` covering
  `WORKSPACE_PKG_NOT_FOUND`, `NO_MATCHING_VERSION_INSIDE_WORKSPACE`,
  the inject branch, and the `publishConfig.directory` /
  `linkDirectory` handling.

Frozen-lockfile installs already record `link:` entries directly; the
new resolution path matters for the no-lockfile install path.

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

* fix(pacquet): dylint + doc-link nits in workspace-protocol port

- Rename single-letter params (perfectionist::single-letter-*).
- Add trailing commas in multi-line macro invocations.
- Avoid the ambiguous `crate::parse_bare_specifier` doc link and the
  cross-crate `pacquet_workspace_spec::WorkspaceSpec` /
  `pacquet_workspace_range_resolver::resolve_workspace_range` doc
  links (the crates don't have a Cargo dependency on each other, so
  rustdoc can't resolve them).

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

* fix(pacquet): address coderabbit review comments

- replace_workspace_protocol_peer_dependency: use replacen("workspace:", "", 1)
  so compound peer specs match upstream JS String.replace's first-only
  semantics; locked with a new test
  peer_workspace_strip_only_removes_first_occurrence.
- npm-resolver module docs: drop the stale "workspace: returns
  Ok(None)" bullet and explain that non-path workspace specs now route
  through try_resolve_from_workspace while path-relative forms still
  fall through to the local resolver.

The third coderabbit nit (read_workspace_manifest error swallowing in
install.rs) was already addressed by the rebase: the workspace manifest
is now read once at the top of Install::run with proper error
propagation, and build_workspace_packages_map takes the pre-loaded
Option<&WorkspaceManifest> instead of re-reading the file.

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

* fix(pacquet/resolving-npm-resolver): surface WorkspacePkgNotFound.hint

Upstream pnpm's WORKSPACE_PKG_NOT_FOUND error carries a 'hint' field
that PnpmError prints as guidance after the message ('Packages found in
the workspace: ...'). The Rust port was populating the field but the
miette diagnostic didn't reference it, so the help text never reached
the user. Add help("{hint}") to the diagnostic attribute so miette
renders it under the message — matching pnpm's output verbatim.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 02:44:22 +02:00
Zoltan Kochan b2a95fa1f7 feat(pacquet): port catalogs (types, protocol-parser, resolver, config) (#11787)
* feat(pacquet): port catalogs (types, protocol-parser, resolver, config)

Adds four new crates mirroring upstream's `catalogs/*` packages:

- pacquet-catalogs-types: `Catalog`/`Catalogs` map aliases plus
  `DEFAULT_CATALOG_NAME`.
- pacquet-catalogs-protocol-parser: `parse_catalog_protocol`; folds
  `catalog:` shorthand into `"default"` to match upstream.
- pacquet-catalogs-resolver: `resolve_from_catalog` with the four
  upstream `PnpmError` codes (`CATALOG_ENTRY_NOT_FOUND_FOR_SPEC`,
  `CATALOG_ENTRY_INVALID_RECURSIVE_DEFINITION`,
  `CATALOG_ENTRY_INVALID_WORKSPACE_SPEC`,
  `CATALOG_ENTRY_INVALID_SPEC`). Rust callers `match` on the result
  enum directly instead of porting the TS-ergonomic
  `matchCatalogResolveResult` visitor.
- pacquet-catalogs-config: `get_catalogs_from_workspace_manifest` plus
  the `INVALID_CATALOGS_CONFIGURATION` mutual-exclusion check.

`pacquet-workspace`'s `WorkspaceManifest` now actually deserializes
the `catalog` and `catalogs` fields (previously dropped). The
resolver is not yet wired into the install path — `deps-installer`
hasn't been ported — but the crates are ready for that next step.

Tests are 1:1 ports of the upstream Jest suites.

* fix(pacquet): satisfy rustdoc and perfectionist lints in catalogs port

- catalogs-resolver: drop the intra-doc-link form on the
  `pacquet_resolving_resolver_base::WantedDependency` reference; the
  crate isn't a dependency, so rustdoc failed to resolve it under
  `-D rustdoc::broken-intra-doc-links`.
- catalogs-resolver, catalogs-config: reorder the `CatalogResolutionError`
  / `InvalidCatalogsConfigurationError` derive lists to
  `Debug, Display, Error, Diagnostic, Clone, PartialEq, Eq` so they
  match the `prefix_then_alphabetical` rule that the CI-only
  Perfectionist dylint enforces. `just ready` doesn't surface this lint
  locally.

* feat(pacquet): resolve catalog: specifiers during install

Wires the catalogs port into the install path:

- `install.rs`: read `pnpm-workspace.yaml` after `find_workspace_dir`
  and normalize via `get_catalogs_from_workspace_manifest` into a
  `Catalogs` map. Adds `InstallError::{ReadWorkspaceManifest,
  InvalidCatalogsConfiguration}` so the upstream
  `ERR_PNPM_INVALID_CATALOGS_CONFIGURATION` propagates verbatim.
- `install_without_lockfile.rs`: threads the `Catalogs` map into
  `ResolveDependencyTreeOptions`. (Frozen-lockfile catalog handling
  needs a lockfile-snapshot pass and is a separate slice.)
- `resolve_dependency_tree`: replaces direct (importer-level)
  `catalog:` bare specifiers with the catalog's recorded version
  before the resolver chain dispatches. Catalog resolution does NOT
  run on transitive deps, matching upstream's importer-only scope.
  Misconfigured entries surface as `CatalogMisconfiguration` with
  the upstream `ERR_PNPM_CATALOG_ENTRY_*` code instead of leaking
  through to `SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`.

Tests:

- deps-resolver: two new unit tests prove direct-dep rewriting and
  the misconfiguration code path.
- cli (e2e): `install_resolves_catalog_protocol` runs the binary
  against a workspace with a `catalog:` entry and checks the
  virtual-store layout; `install_surfaces_catalog_misconfiguration`
  asserts the upstream message is surfaced when the catalog has no
  matching alias.

* style(pacquet): apply rustfmt to catalogs test after rebase

* fix(pacquet): rename single-letter closure param in catalogs e2e test

Perfectionist's `single_letter_closure_param` lint (CI-only via
Dylint) flagged `|c|` in the box-drawing-strip filter.
2026-05-21 02:06:05 +02:00
Zoltan Kochan df990fdb51 feat(pacquet): port node/deno/bun runtime resolvers (#11783)
* feat(pacquet): port node/deno/bun runtime resolvers and wire them into the install chain

Ports the three `@pnpm/engine.runtime.*-resolver` packages and the shared
`@pnpm/crypto.shasums-file` helper into pacquet, and slots them into the
default-resolver chain so `node@runtime:<spec>`, `deno@runtime:<spec>`,
and `bun@runtime:<spec>` resolve through pacquet as they do in pnpm.

New crates under `pacquet/crates/`:

- `crypto-shasums-file` — downloads and decodes `SHASUMS256.txt`,
  shared by node and bun. Mirrors `FAILED_DOWNLOAD_SHASUM_FILE`,
  `NODE_INTEGRITY_HASH_NOT_FOUND`, `NODE_MALFORMED_INTEGRITY_HASH`.
- `engine-runtime-node-resolver` — `parse_node_specifier`,
  `get_node_mirror`, `get_node_artifact_address`, `normalize_arch`,
  `resolve_node_version[s]`, and the `Resolver`-impl entry point.
  Handles the unofficial-musl mirror fan-out, the `lts` / LTS-codename
  / channel / range selectors, and the `darwin/arm64 <16 → x64`,
  `win32/ia32 → x86`, `arm → armv7l` arch quirks. Error codes
  `NO_OFFLINE_NODEJS_RESOLUTION`, `NODEJS_VERSION_NOT_FOUND`,
  `INVALID_NODE_RELEASE_CHANNEL` match upstream.
- `engine-runtime-deno-resolver` — version selection delegates to the
  npm resolver; assets come from the GitHub Releases API + per-asset
  SHA256 sidecars. Windows x64 covers arm64 under emulation.
  Errors: `DENO_RESOLUTION_FAILURE`, `DENO_MISSING_ASSETS`,
  `DENO_GITHUB_FAILURE`, `DENO_PARSE_HASH`.
- `engine-runtime-bun-resolver` — version selection delegates to npm;
  assets come from the GitHub-release `SHASUMS256.txt`. `windows` /
  `aarch64` are normalised to `win32` / `arm64`. Error:
  `BUN_RESOLUTION_FAILURE`.

Wiring (`install_without_lockfile.rs`): chain order is now
`npm → git → node → deno → bun`, matching upstream's
`resolving/default-resolver/src/index.ts` at 1627943d2a. The npm
resolver is shared via `Arc<dyn Resolver>` so the deno/bun resolvers
reuse the same metadata cache; a small `ArcResolver` adapter bridges
that to `DefaultResolver`'s `Vec<Box<dyn Resolver>>`.

Out of scope (called out in code):
- `currentPkg && !update` short-circuit isn't restored yet — needs
  `ResolveOptions::current_pkg` first. The resolver re-fetches the
  asset list on every install.
- `nodeDownloadMirrors` defaults to empty. Wiring it through the
  config layer is a follow-up.

* fix(pacquet): silence rustdoc errors on runtime-resolver doc comments

CI's `Doc` job runs rustdoc with `-D warnings`. Three intra-doc links
in this PR's new doc comments tripped it:

- `crypto-shasums-file` referenced `pacquet_lockfile::BinaryResolution`
  but the crate doesn't (and shouldn't) depend on `pacquet-lockfile`.
  Drop the link and leave the name as plain text.
- `engine-runtime-deno-resolver` linked `DefaultResolver` to
  `pacquet_resolving_default_resolver::DefaultResolver` — same crate-
  dependency story. Rewrite the prose to mention "the default-resolver
  chain" without a link.
- `engine-runtime-deno-resolver` doc comment on the public
  `ReadDenoAssetsError` linked to the private free function
  `read_deno_assets`. Point at the public re-export
  `DenoResolverError::ReadAssets` instead so the link is reachable
  from generated docs.
- `engine-runtime-bun-resolver` had a redundant explicit link target
  on `PlatformAssetResolution` (label and target resolve to the same
  item). Drop the redundant target and reword from `…s` to `… entries`
  so the link label doesn't carry a stray pluralisation `s`.

* fix(pacquet): drop redundant explicit target on PlatformAssetResolution link

The target resolves to the same item as the label (the type is imported
into scope further down in the same file), so rustdoc with
`-D rustdoc::redundant-explicit-links` rejects the form. Drop the
target and let intra-doc resolution pick it up via the existing use.

* fix(pacquet): satisfy CI Doc + Dylint on runtime-resolver crates

The pacquet CI runs rustdoc and `cargo dylint` (perfectionist lints)
with `-D warnings`, both of which catch issues `just ready` doesn't:

- rustdoc on `engine-runtime-node-resolver/lib.rs` reported
  `parse_node_specifier` / `get_node_mirror` / `get_node_artifact_address`
  as ambiguous between the module and the same-named function the
  module re-exports. Disambiguate by appending `()` to the link label
  so rustdoc resolves to the function.
- dylint's `perfectionist::single-letter-closure-param` flagged the
  `|c|` parameter in `parse_node_specifier::prerelease_channel`.
  Rename to `next` and break the chain so the body stays readable.
- dylint's `perfectionist::prefer-raw-string` flagged the regex literal
  on `NODE_EXTRAS_IGNORE_PATTERN`. Convert to a raw string so the
  backslash before `.` reads as the regex escape it is.
- dylint's `perfectionist::macro-trailing-comma` flagged the
  multi-line `matches!` invocation in the shasums-file `NotFound`
  test. Re-shape with the trailing comma and split across lines.

* fix(pacquet): re-flow prerelease_channel closure to keep rustfmt happy

* fix(pacquet): address PR review on runtime-resolver port

Five behavioral / hygiene fixes the CodeRabbit review surfaced that
hold up against the upstream pnpm source:

- `crypto-shasums-file`: tighten `is_sha256_hex` from
  `is_ascii_hexdigit` (accepts `A-F`) to lowercase-only `0-9a-f`,
  matching upstream's `/^[a-f0-9]{64}$/`.
- `engine-runtime-node-resolver/resolve_node_version`: thread
  `error_for_status` into the `fetch_all_versions` GET so non-2xx
  responses from `index.json` surface as `FetchIndex` rather than
  being read into text and decoded as `DecodeIndex`. Matches the
  existing convention in `resolving-npm-resolver/fetch_full_metadata`.
- `engine-runtime-node-resolver/resolve_node_version`: introduce
  `satisfies_with_prereleases` mirroring the strategy already used in
  `resolving-deps-resolver/resolve_peers` so range selectors like
  `rc/18` pick up `18.0.0-rc.X` candidates. Upstream's
  `semver.maxSatisfying(...)` runs with `includePrerelease: true`;
  `node-semver` Rust does not — strip the prerelease suffix on a
  failed straight check and retry against the base version.
- `engine-runtime-deno-resolver/read_deno_assets`: same
  `error_for_status` fix on the GitHub Releases API call so a 404 or
  rate-limit response is a `FetchReleaseIndex` failure, not a JSON
  decode error.
- `package-manager/install_without_lockfile`: also drop the
  standalone `npm_resolver` Arc binding after the resolve pass.
  `drop(resolver)` only releases the `DefaultResolver` chain (one
  strong reference); the `npm_resolver` local kept a second strong
  reference because the deno- and bun-resolvers were handed clones
  of the same `Arc`. Without the explicit drop the packument cache
  stays alive through every fetch/import/link, which the comment
  above already says we want to avoid.

Plus one test-only addition (a `darwin/arm` passthrough assertion in
`normalize_arch/tests.rs`) that pins the upstream behavior — pnpm
applies the `arm → armv7l` quirk unconditionally, including outside
Linux. Locking that in keeps a well-meaning future "Linux-only"
narrowing from silently diverging from pnpm.

Other CodeRabbit suggestions (propagate caller opts on the
npm-delegation calls, error on empty Deno variants, offline guard in
`resolve_latest_impl`, scope `arm` rewrite to Linux) all reflect
behaviors that *upstream pnpm doesn't have* — adopting any of them
would break the parity contract in
[`pacquet/AGENTS.md`](pacquet/AGENTS.md). Left in place.

* fix(pacquet/deno-resolver): surface hex-decode failures as DENO_PARSE_HASH

`fetch_sha256` already guarantees the returned string is a 64-char
lower-case hex run, so `decode_hex` cannot fail in practice. Drop the
`unwrap_or_default()` fallback (which would silently feed an empty
byte slice into the integrity construction and then trip an opaque
\`Integrity\` parse error downstream) in favor of an explicit
`ParseHash` error, so a future change that loosens `extract_sha256`'s
validator surfaces with the right code instead of obscuring the
failure shape.
2026-05-21 01:32:40 +02:00
Zoltan Kochan d08e9abfb9 fix(pacquet/lockfile): accept link: refs in snapshot dependencies (#11788)
* fix(lockfile): accept link: refs in snapshot dependencies

An injected workspace package's snapshot can carry a `link:<path>`
dependency value when the dep is a workspace sibling. Pnpm accepts
the shape — `refToRelative` short-circuits to `null` for `link:` —
but pacquet's `SnapshotDepRef` only handled the plain/alias shapes
and rejected `link:` at parse time.

Add a `SnapshotDepRef::Link(String)` variant mirroring
`ImporterDepVersion::Link`, return `None` from `resolve` / `ver_peer`
for it, and skip it at every consumer (matching upstream's
`if (childDepPath)` guards in `getChildren` /
`lockfileDepsToGraphChildren`).

Fixes https://github.com/pnpm/pnpm/issues/11775.

* test(lockfile): trim redundant doc comments on link snapshot dep tests

Drop test doc-comments that restated the assertion. The test name +
asserts already convey the intent; the upstream-parity reason lives
at the `SnapshotDepRef::resolve` / `Link` variant docs.
2026-05-21 01:30:32 +02:00
Zoltan Kochan ee8fd0d4cf feat(pacquet): port auto-install-peers algorithm (#11784)
* feat(pacquet): port auto-install-peers algorithm

Replaces the placeholder peer-folding behavior with a faithful port of
pnpm's `hoistPeers` algorithm. Missing required peers are hoisted to
the importer's direct deps (shared across consumers, not nested), with
a multi-pass loop that re-resolves until no required peer remains and
the optional-peer pass picks already-available versions from the
preferred-versions map.

- New `pacquet-lockfile-preferred-versions` crate seeds the version-
  picker tie-break table from manifest + lockfile snapshots.
- New `hoist_peers` module ports `hoistPeers` and
  `getHoistableOptionalPeers` with the full upstream test suite.
- New `resolve_importer` orchestrator drives the multi-pass hoist
  loop and threads `parent_pkg_aliases` / `all_preferred_versions`
  across iterations.
- `resolve_dependency_tree` exposes `TreeCtx` / `extend_tree` /
  `snapshot` so the orchestrator can extend the tree incrementally
  without re-walking already-resolved subtrees.
- `auto-install-peers-from-highest-match` config setting added,
  mirroring upstream's flag for range-merge behavior.

Ported from pnpm's installing/deps-resolver `resolveRootDependencies`
and hoistPeers.ts at commit 097983fbca.

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

* chore: drop changeset

Pacquet changes don't get changesets.

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

* test(pacquet): port single-importer auto-install-peers cases

Port nine portable scenarios from
installing/deps-installer/test/install/autoInstallPeers.ts as
orchestrator-level unit tests in resolve_importer/tests.rs:

- skip optional peers without a preferred-version hint
- dedupe via range intersection when consumers agree
- drop the peer when ranges conflict (default)
- install via `||` join when autoInstallPeersFromHighestMatch is on
- reuse a peer already brought by a sibling (preferred-versions)
- skip hoisting when the root already has the dep as a direct
- collapse the same missing peer across a transitive chain
- prefer the version pinned in the importer's own peerDependencies
- reuse a regular-dep version over re-resolving via the peer arm

The last case exposed a gap: the orchestrator wasn't including the
importer's own `peerDependencies` as initial wanted deps when
auto-install-peers is on, mirroring upstream's
`getAllDependenciesFromManifest({ autoInstallPeers: true })`. Fixed
in resolve_importer alongside the test ports.

Workspace, frozen-lockfile mutation, hook-using, override-using,
and webpack-circular-deps tests from the upstream file remain
un-ported pending the matching features in pacquet.

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

* fix(pacquet): satisfy Dylint Perfectionist + rustdoc checks

CI's Dylint job (Perfectionist lints) and the Doc job both fail on
the prior commits' style. Cleanups:

- Rename single-letter closure params (`|c|`, `|d|`, `|e|`, `|r|`)
  to descriptive names in resolve_importer, hoist_peers,
  version_selector_type, and the install + orchestrator test files.
  Triggered `perfectionist::single-letter-closure-param`.
- Add trailing commas to multi-line `assert!` invocations in
  resolve_importer/tests.rs. Triggered
  `perfectionist::macro-trailing-comma`.
- Disambiguate `[`crate::resolve_importer`]` and friends with the
  `[`fn@crate::resolve_importer`]` form — `resolve_importer`,
  `resolve_peers`, `hoist_peers`, and `get_hoistable_optional_peers`
  are each both a module and a re-exported function, which rustdoc
  flags as ambiguous.
- Replace the broken `[`resolve_dependency_tree`]` and
  `[`resolve_peers`]` intra-doc links in install_without_lockfile.rs
  with prose now that those symbols are no longer in scope there
  (the orchestrator wraps them).
- Drop the link to the private `add_weight_to_version_selector`
  helper from `get_preferred_versions_from_lockfile_and_manifests`'
  public docs and the link to the private `resolve_node` from
  `extend_tree`'s public docs.
- Remove the redundant explicit `(crate::extend_tree)` link target
  in lib.rs's module doc.

No behavior change; tests pass.

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

* fix(pacquet): include prereleases in hoist-peers max_satisfying

Upstream's `hoistPeers` and `getHoistableOptionalPeers` both pass
`{ includePrerelease: true }` to `semver.maxSatisfying`, so a
prerelease candidate from the preferred-versions table (e.g.
`18.0.0-rc.1`) satisfies a regular range (e.g. `^18.0.0`). Rust's
`node_semver::Range::satisfies` follows strict semver semantics and
rejects prereleases when the range has none of its own, which
silently dropped valid picks in the hoist loop.

Mirror the strip-and-retry pattern already used by
`satisfies_with_prereleases` in `resolve_peers.rs`: a new
`satisfies_including_prerelease` helper in `hoist_peers.rs` retries
with the prerelease tag stripped, and `max_satisfying` /
`get_hoistable_optional_peers` now both go through it.

Add two regression tests covering an `^18.0.0` range against an
`18.0.0-rc.1` preferred-versions entry for each function.

Reported by CodeRabbit on PR #11784.

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

* fix(pacquet): disambiguate intra-doc link to resolve_peers

The doc comment I added for `satisfies_including_prerelease` linked
to `crate::resolve_peers`, which rustdoc flags as ambiguous (both a
module and a re-exported function). Replace the link with prose
since the helper this paragraph compares to is module-internal
anyway — no link can resolve to it from outside the module.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 01:22:57 +02:00
Zoltan Kochan e2791ab6fe feat(pacquet): port named registries to the install chain (#11785)
* feat(pacquet): port named registries to the install chain

Adds the user-facing `<alias>:` resolver surface so a manifest entry
like `"@acme/private": "gh:^1.0.0"` resolves against GitHub Packages
(or any other registry the user configures under
`namedRegistries:` in `pnpm-workspace.yaml`).

Mirrors upstream pnpm/pnpm@b61e268d57:

- `parse_named_registry_specifier_to_registry_package_spec` parses
  `<alias>:[@<owner>/]<name>[@<version>]` and `<alias>:<version>`
  bodies. Rejects scope-without-name with
  `ERR_PNPM_INVALID_NAMED_REGISTRY_PACKAGE_NAME`.
- `merge_named_registries` folds the user map onto pacquet's
  built-in aliases (`gh:` -> GitHub Packages) and validates URLs at
  resolver construction (`ERR_PNPM_INVALID_NAMED_REGISTRY_URL`).
- `NamedRegistryResolver` is a third `Resolver` alongside the npm /
  jsr paths, emitting `resolved_via: "named-registry"`. Auth headers
  flow through the existing per-URL `.npmrc` lookup.
- `Config::named_registries` reads `namedRegistries:` from
  `pnpm-workspace.yaml`, with `${VAR}` substitution on the values
  (matches upstream's `replaceEnvInStringValues`).
- `install_without_lockfile` constructs the merged map once and
  threads it through both the resolver chain (after npm/git, so
  configured aliases cannot hijack built-in schemes) and
  `build_resolution_verifiers` (so tarball-URL prefix routing
  honours user aliases).

* fix(pacquet): rustdoc broken links + spell-check typo

- `resolving-local-resolver/src/chain.rs`: drop the redundant
  explicit `(crate::...)` target on `[resolve_latest_from_local]`
  (clippy's `redundant_explicit_links` lint flagged it under
  `RUSTDOCFLAGS=-D warnings`) and demote the `[contains_path_sep]`
  intra-doc reference to backticks since the helper is private.
- `resolving-npm-resolver/src/parse_bare_specifier.rs`: rename
  `unparseable` to `unparsable` in a doc comment so the workspace
  typos check passes.

* test(pacquet): cover the remaining named-registry test cases

Ports upstream's
[`resolving/default-resolver/test/namedRegistry.ts`](https://github.com/pnpm/pnpm/blob/b61e268d57/resolving/default-resolver/test/namedRegistry.ts)
and the two `resolveNamedRegistry.test.ts` cases the previous
commit hadn't covered.

- New `tests/chain.rs` integration tests assert that the explicit
  `link:` / `workspace:` / `file:` schemes win over a colliding
  named-registry alias. These pin the local-scheme / local-path
  split: with combined `LocalResolver`, named-registry would slot
  *after* both halves and a `link` alias could hijack `link:./pkg`.
- `resolves_via_builtin_gh_alias` covers the `gh:` happy path that
  was previously only exercised via a user-supplied `work:` alias.
- `preserves_scoped_pkg_name_when_alias_differs` verifies that the
  resolver records the dependency under the registry's name, not
  the local manifest alias.
- `user_config_overrides_builtin_gh_alias` covers the GHES override
  scenario where a user points `gh` at their enterprise host.

11 resolver tests pass (5 unit + 3 chain integration + 3 happy-path
scenarios) plus the 12 parser cases already in place.
2026-05-21 01:07:13 +02:00
Zoltan Kochan 998ea4cd37 fix(pacquet/lockfile): accept non-semver version slots (URLs, git refs) (#11786)
* fix(pacquet/lockfile): accept non-semver version slots (URLs, git refs)

Pnpm's `parse` in `deps/path/src/index.ts` falls back to the
`nonSemverVersion` branch whenever `semver.valid` rejects the version
slot, so a `https://codeload.github.com/...` tarball URL or `git+...`
URL parses cleanly. Pacquet's `PkgVerPeer` parser was strict and
errored on anything that wasn't a semver or a `file:` path, which made
the lockfile reader fail with `Failed to parse importer dependency
version "https://codeload..."` on the install path.

Add `VersionPart::NonSemver(String)` and route the parser's bare-version
fallback through it, mirroring upstream byte-for-byte. Closes the parse
failure reported in pnpm/pnpm#11776.

* fix(pacquet/lockfile): add trailing commas to satisfy dylint perfectionist
2026-05-21 01:02:46 +02:00
Zoltan Kochan 4b25d60f62 fix(pacquet/patching): apply patches without mutating the store (#11782)
Reported against pacquet's configDependencies preview engine for `msw@2.12.14`: install fails with `ERR_PNPM_PATCH_FAILED ... error applying hunk #1` even though the patch is perfectly valid. Original report (patch file + error log): [Stanzilla/21648ae644068c3aef3d18693c7c32c9](https://gist.github.com/Stanzilla/21648ae644068c3aef3d18693c7c32c9).

**Root cause.** When two snapshots of the same patched package hardlink (or reflink, where the filesystem doesn't break-on-write) from the same store file, pacquet's `apply_patch_to_dir` did a plain `fs::write` — which truncates and writes through the shared inode, **corrupting the on-disk store copy** and leaking patched content into every other snapshot that linked the same file. The next worker then read already-patched content and `diffy::apply` failed on the missing `-` lines. The patched output already lives in the side-effects cache after apply returns, so nothing requires the store copy to carry it.

The fix is three layered changes to `apply_patch_to_dir`:

- **Atomic temp + rename for Modify** ([apply.rs](pacquet/crates/patching/src/apply.rs)). Stage the patched bytes in a sibling `.{name}.{pid}.{counter}.pacquet-tmp` opened with `create_new(true)` (no symlink-follow, no truncation of an attacker-pre-seeded path), chmod the temp to match the original *before* the rename so the final mode lands atomically, then `fs::rename` over the target. `rename` is atomic on Unix and replaces in-place on Windows, so an IO failure mid-write leaves either the original or the rewritten file — never an empty dirent. As a side effect of creating a new dirent → inode mapping, it also breaks any hardlink the path previously shared with the store. Matches the [`pacquet_lockfile::save_lockfile::write_atomic`](pacquet/crates/lockfile/src/save_lockfile.rs) pattern.
- **Idempotency via reverse-dry-run** (Modify/Create/Delete). When forward apply fails, try reverse-apply against the on-disk content; if it succeeds the file is already in the post-patch state and we no-op. Mirrors upstream [`@pnpm/patch-package`'s `applyPatch`](https://github.com/ds300/patch-package/blob/master/src/applyPatches.ts) which retries `executeEffects(reversePatch, { dryRun: true })` on failure. Defense in depth for re-runs that find the directory pre-patched (side-effects-cache hit that fell through, manual edits, partial install recovery) — the hardlink-break above prevents fresh installs from ever producing that state.
- **Idempotency for Create and Delete**. Create skips when the existing file already contains the post-patch content; Delete treats `NotFound` on the target as already-deleted.

Equivalent fix for the TS pnpm CLI side is in [`pnpm/patch-package@c97a62e`](https://github.com/pnpm/patch-package/commit/c97a62e) (`@pnpm/patch-package` is the apply implementation pnpm imports); pnpm will pick it up once that package is released and the dep gets bumped.
2026-05-21 00:37:21 +02:00
Zoltan Kochan a8a8cbce6d feat(pacquet): port resolving.local-resolver (file:/link:/workspace:) (#11778)
* feat(pacquet/resolving-local-resolver): port file:/link:/workspace: resolver

Ports pnpm's @pnpm/resolving.local-resolver:

- parse_bare_specifier mirrors parseLocalScheme/parseLocalPath/fromLocal
  (link:/workspace:/file: prefixes, bare path shapes, tarball-filename
  detection, tilde/drive-letter handling, preserveAbsolutePaths,
  injected directories get file: not link:).
- local_resolver provides resolve_from_local_scheme / _path /
  resolve_latest_from_local matching upstream's three exports;
  ssri-based tarball integrity for file: tarballs;
  safe_read_package_json_from_dir for directories with the upstream
  fallback (warn + name=basename / version='0.0.0' for missing link:
  targets, LINKED_PKG_DIR_NOT_FOUND for missing file: targets,
  NOT_PACKAGE_DIRECTORY for ENOTDIR + Windows stat-check) and
  PATH_IS_UNSUPPORTED_PROTOCOL for path:.
- chain.rs wraps the free functions behind the Resolver trait so the
  default-resolver dispatcher can compose this in alongside the npm
  resolver. ResolveResult.name_ver is None for local resolutions —
  the canonical name lives in the fetched manifest, not the
  resolver-time signal.

17 ported tests mirror resolving/local-resolver/test/index.ts plus
3 chain-dispatch tests verifying the trait wiring. The missing-link:
target warn is emitted via tracing::warn! because pacquet's reporter
doesn't yet have a generic pnpm:logger channel.

Install-side wiring is left for a follow-up alongside the Stage-1
directory-fetcher integration: surfacing Directory resolutions to
install_without_lockfile today would only swap the
SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER error for an UnsupportedResolution
one in install_package_from_registry.

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

* fix(pacquet): satisfy doc + dylint CI

Doc:
- pacquet_directory_fetcher intra-doc link was unresolved
  (resolving-local-resolver has no such dep — it's a sibling).
- LocalSpecError doc linked to crate-private parse_local_scheme /
  parse_local_path.

Dylint (perfectionist):
- PkgResolutionId / WantedLocalDependency / ParseOptions /
  PathProtocolNotSupportedError derive lists reordered to
  prefix_then_alphabetical.
- Single-letter closure params (|p|, |s|, |v|) renamed.
- Impure expression passed to tracing::warn! bound to a let first.
- Multi-line format!/assert_eq! macro invocations gained trailing
  commas; the single-line assert! shed its stray trailing comma.

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

* fix(pacquet/resolving-local-resolver): surface NOT_PACKAGE_DIRECTORY on Windows

`safe_read_package_json_from_dir` opens `<spec>/package.json` and lets
the OS error surface. On Unix that's `ENOTDIR` for a file path; on
Windows it's `NotFound`, so the resolver fell through to the
fallback-manifest branch instead of returning `NOT_PACKAGE_DIRECTORY`.

Upstream pnpm has the same gap on Windows and patches around it inside
`readProjectManifestOnly` (workspace/project-manifest-reader/src/index.ts#L100-L114
at ef87f3ccff) by stat-checking the spec and synthesizing ENOTDIR.
Mirror the check here so `link:./foo.tgz` raises NOT_PACKAGE_DIRECTORY
on every platform.

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

* fix(pacquet/resolving-local-resolver): raise LINKED_PKG_DIR_NOT_FOUND for missing tarballs

The `file:` tarball branch wrapped every read failure in
`ResolveLocalError::Integrity`, so `file:./missing.tgz` lost the
pnpm-compatible error code. The directory branch already maps the
same scenario to `LINKED_PKG_DIR_NOT_FOUND`; thread the tarball case
through the same code by short-circuiting on `NotFound` from
`compute_tarball_integrity`. Mirrors upstream's `resolveSpec` catch,
where `getTarballIntegrity`'s ENOENT funnels into the same
LINKED_PKG_DIR_NOT_FOUND throw the directory branch raises.

Adds a `fail_when_resolving_missing_tarball_with_file_protocol` test
to pin the contract.

Resolves a CodeRabbit review comment on #11778.

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

* feat(pacquet/package-manager): wire LocalResolver into install_without_lockfile chain

`install_without_lockfile`'s resolver chain was `[npm, git]` — `link:`
/ `file:` / `workspace:` and bare-path specifiers raised
`SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER` even though the
`resolving-local-resolver` crate that handles them has been ported.
Slot `LocalResolver` in after git, mirroring upstream's
`createResolver` order (npm → jsr → git → tarball → local-scheme → …
→ local-path).

Pacquet doesn't expose `preserveAbsolutePaths` through `Config` yet
so the context defaults to `false`; once that setting lands in
`pacquet-config` the install path can thread it through.

The install pass still can't materialise Directory resolutions — the
tarball-shaped install path raises `UnsupportedResolution` — but the
resolver chain now correctly dispatches to the local resolver, so
the failure mode moves one step closer to the install-side gap
that's tracked by the Stage-1 `directory-fetcher` integration.

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

* fix(pacquet): satisfy cargo fmt --check

Local cargo fmt formatted the `fail_when_resolving_missing_tarball_with_file_protocol`
struct literal on one line; the CI Format job (`cargo fmt --all -- --check`)
disagreed. Re-run fmt locally to flush the diff.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-20 23:49:30 +02:00
Zoltan Kochan 606ff8f648 feat(pacquet): port resolving/tarball-resolver (#11773)
* feat(pacquet): port resolving/tarball-resolver

Adds pacquet-resolving-tarball-resolver, the Rust port of
resolving/tarball-resolver/src/index.ts. The new crate claims any
WantedDependency whose bare specifier starts with `http://` or
`https://`, normalizes the URL through `reqwest::Url::parse`, runs a
HEAD pre-flight that follows redirects, and stores the post-redirect
URL in the resolution when the response carries
`cache-control: immutable`. Mutable responses keep the normalized
request URL. Integrity stays None at resolve time, matching upstream
(integrity is stamped later in package-requester).

To make the seam fit, ResolveResult.id is now an opaque
`PkgResolutionId(String)` newtype in resolver-base mirroring
upstream's branded string at core/types/misc.ts:59. PkgNameVer was a
poor fit because tarball ids are URLs and git ids are
`repo#commit` — not name@version. NpmResolver round-trips the
existing PkgNameVer through PkgResolutionId::from(string); the
npm-only install paths in package-manager parse the id back to
PkgNameVer at their boundary (safe because the npm resolver stamps
that shape). The deps-resolver alias fallback drops its `.id.name`
access since the id is opaque now.

Test coverage in the new crate (7 tests, mockito): http(s) claim
vs decline, mutable vs immutable response, immutable-after-redirect
follow, resolve_latest for http(s) vs non-http(s).

* fix(pacquet/resolving-tarball-resolver): rename single-letter closure params

Dylint's perfectionist::single-letter-closure-param rejects |v|; rename
to |header| in the cache-control header check.

* feat(pacquet/package-manager): wire TarballResolver into the install chain

Insert TarballResolver after the GitResolver in install_without_lockfile's
DefaultResolver chain so http(s):// bare specifiers actually route through
the new resolver. Order mirrors upstream's chain
(npm → git → tarball → local/...).

* fix(pacquet/resolving-tarball-resolver): forward wanted alias and resync lockfile

- Echo `wanted_dependency.alias` on the `ResolveResult` so a spec
  like `"foo": "https://.../bar.tgz"` preserves `foo` as the
  install name downstream. Matches the npm and git resolvers'
  convention even though upstream TS doesn't surface alias on
  `ResolveResult` (downstream consumers fall back to
  `wantedDependency.alias` over there).
- Drop a stray blank line in resolve.rs that cargo fmt rejected.
- Record the new `package-manager -> resolving-tarball-resolver`
  edge in Cargo.lock so `cargo build --locked` succeeds on CI.
2026-05-20 23:33:26 +02:00
Zoltan Kochan 35d5440ce1 feat(pacquet): port resolving/git-resolver and wire it into the install chain (#11779)
* feat(pacquet): port resolving/git-resolver and wire it into the install chain

Adds `pacquet-resolving-git-resolver`, the Rust port of pnpm's
`@pnpm/resolving.git-resolver`. Recognises GitHub / GitLab / Bitbucket
shortcut forms and full `git+ssh:` / `git+https:` / `ssh:` / plain
`https://…/repo.git` URLs, runs `git ls-remote` to pin the commit
(partial commit search, annotated-tag dereference, semver-range matching),
and emits either a git-hosted tarball resolution or a `Git{repo,commit}`
resolution. Production runners shell out to the system `git` binary via
`tokio::task::spawn_blocking` and use the install-wide
`ThrottledClient` for the HEAD probe.

Widens the resolver-base contract so URL-shaped IDs fit: adds a
`PkgResolutionId` newtype (rule-3 branded string, infallible
`From<String>`/`From<&str>`/`From<&PkgNameVer>`), changes
`ResolveResult.id` to that type, and adds `name_ver: Option<PkgNameVer>`
so callers that need the structured `name@version` form keep working.
npm-resolver fills both fields; git-resolver leaves `name_ver` `None`
(the install path that consumes git resolutions hasn't landed yet, so
those call sites panic with a TODO message until then).

`DefaultResolver` now implements `Resolver` too (returns `Ok(None)`
when no resolver in the chain claims), letting `resolve_dependency_tree`
accept the chain directly. The install-side wiring in
`install_without_lockfile.rs` constructs
`DefaultResolver::new(vec![Box::new(npm_resolver), Box::new(git_resolver)])`
with `RealGitProbe` / `RealGitRunner`, mirroring upstream's
`createResolver` chain order.

Test coverage: 51 unit tests in the new crate, including the full
SCP-style URL repair matrix ported from `parsePref.test.ts` and the
GitLab `/-/archive/` tarball regression for pnpm #11533. Full
workspace `cargo nextest run` is green at 1635 tests.

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

* fix(pacquet): satisfy dylint perfectionist + rustdoc on git-resolver port

* Reorder `#[derive(...)]` on `PkgResolutionId` to match the
  `prefix_then_alphabetical` rule the dylint Perfectionist lint
  enforces (`From` last after `Serialize`/`Deserialize`).
* Add `()` to function intra-doc links that collide with same-named
  modules (`create_git_hosted_pkg_id`, `parse_bare_specifier`) so
  rustdoc's `broken-intra-doc-links` lint stops treating them as
  ambiguous.

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

* fix(pacquet): satisfy Perfectionist dylint lints on git-resolver port

CI's `just ready` doesn't surface Perfectionist (it runs only as a
dedicated dylint job on a nightly toolchain). Fixes:

* Rename single-letter generics `P`/`R` → `Probe`/`Runner` on
  `GitResolver`, `PartialSpec::finalize`, `from_hosted_git`, and
  `resolve_ref`.
* Rename single-letter closure / function / let-binding params
  (`s`/`h`/`c`/`p`/`i`/`g`/...) to descriptive names.
* Replace Unicode ellipsis (`…`, U+2026) with ASCII `...` in comments.
* Add trailing commas to multi-line `assert_eq!` / `assert!`
  invocations, and remove the stray trailing comma on a single-line
  one.

Also fix follow-on JSR-resolver test cases that still read
`result.id.{name,suffix}`: switch them to `result.name_ver.as_ref()...`
to match the post-widening `ResolveResult` shape.

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

* fix(pacquet): address PR review on git-resolver port

* Replace the two `.expect()` calls on `ResolveResult.name_ver` in the
  install path with `.ok_or_else()` that surfaces a typed error:
  `InstallPackageFromRegistryError::UnsupportedResolution` and a new
  `InstallWithoutLockfileError::UnsupportedInstallResolution`. Now
  that the git resolver is in the chain, a git/tarball/local
  resolution reaching the without-lockfile install path returns an
  error end-to-end instead of panicking. Add a regression test
  pinning the contract.
* Make `percent_decode` (in `hosted_git.rs`) and `percent_decode_str`
  (in `parse_bare_specifier.rs`) UTF-8 aware: collect decoded bytes
  into a `Vec<u8>` and reassemble via `String::from_utf8`, falling
  back to the original input on malformed UTF-8 (matches Node's
  `decodeURIComponent` throwing a `URIError` that upstream's
  `try/catch` swallows). The byte→`char` cast was corrupting any
  multi-byte sequence (e.g., `%E2%80%A6` → ellipsis); regression test
  added.

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

* chore(pacquet): drop unused UnsupportedInstallResolution after rebase

Main's `feat(pacquet): peer-dependency resolution stage` reworked
`install_without_lockfile.rs` to derive the virtual-store name from
the resolved depPath via `pacquet_deps_path::dep_path_to_filename`
instead of reading `result.name_ver`. That removed the `.expect()` /
`.ok_or_else()` site this error variant was added for; with no
remaining callers, drop the dead variant.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-20 23:07:16 +02:00
Zoltan Kochan a1f91e1770 feat(pacquet): peer-dependency resolution stage (#11774)
* feat(pacquet): peer-dependency resolution stage

Ports pnpm's `resolvePeers` algorithm to pacquet's `resolving-deps-resolver`
crate and wires it into `install_without_lockfile`. The depPath-keyed
`DependenciesGraph` produced by the new stage replaces the flat `(name, version)`
keying the install pass used to drive virtual-store slot names from.

New `pacquet-deps-path` crate ports the `@pnpm/deps.path` helpers
(`createPeerDepGraphHash`, `depPathToFilename`, balanced-paren suffix scan).

`ResolvedTree` now carries both the flat dedup map (`packages`) and the
per-occurrence tree (`dependencies_tree`, keyed by a new `NodeId`) so two
parents sharing the same package can compute different peer suffixes — the
whole point of the stage. Cycles in the tree pass are broken by skipping the
cycled edge entirely (matches upstream's `parentIdsContainSequence` gate in
`buildTree`), and peer-resolution falls back to `name@version` peer-ids when
re-entering an in-progress node.

Three upstream optimisations are intentionally not ported in this slice:
`peersCache`, the `purePkgs` fast path, and `graph-cycles`-driven async
deferment. Each is correctness-preserving — the algorithm produces the same
depPaths, just without the short-circuits. `autoInstallPeers` keeps its existing
"fold peers into the regular walk" behavior until the full `hoistPeers`
algorithm lands.

* fix(pacquet/deps-path): satisfy Doc / Dylint / Spell Check CI checks

- Dylint perfectionist::single-letter-let-binding: rename `i` → `cursor`
  in `suffix_index.rs`.
- Dylint perfectionist::macro-trailing-comma: drop trailing comma in the
  `dep_path_to_filename` `file:` test.
- rustdoc broken-intra-doc-links: link `[`DependenciesTree`]` to the
  newly-exported `pacquet_resolving_deps_resolver::DependenciesTree`
  type alias and downgrade the `pacquet_lockfile::PkgNameVerPeer` mention
  in `pacquet-deps-path`'s module doc to plain text (the crate
  deliberately doesn't depend on `pacquet-lockfile`).
- rustdoc fn-vs-mod ambiguity: prefix [`resolve_dependency_tree`],
  [`resolve_peers`], and [`create_peer_dep_graph_hash`] doc-links with
  `fn@` so they bind to the function rather than the same-named module.
- typos: rename `unparseable` → `unparsable` in a test name.

* fix(pacquet/resolving-deps-resolver): satisfy Dylint perfectionist lints

- `derive_ordering`: reorder `#[derive(...)]` on `DepPath` and `NodeId`
  to match the configured `prefix_then_alphabetical` style — `PartialOrd,
  Ord, Hash` rather than `Hash, PartialOrd, Ord`. See `dylint.toml`'s
  `[perfectionist::derive_ordering]` prefix list for the canonical order.
- `single_letter_function_param`: rename `s` → `value` on
  `impl From<String> for DepPath`.

* fix(pacquet): address PR review feedback

Round of correctness fixes flagged by CodeRabbit and qodo-code-review on
#11774.

- **`dep_path_to_filename_unescaped`**: guard against empty / single-byte
  input. The previous `trimmed.as_bytes()[1..]` slice panicked when
  `trimmed.len() < 2`; the function now short-circuits to
  `trimmed.to_string()` in that case, mirroring upstream's `indexOf` with
  `fromIndex = 1` returning -1 on out-of-range scan.

- **`extract_children` / `extract_peer_dependencies` mismatch**: the
  child walker added every `peerDependencies` entry when
  `auto_install_peers` was on, but the peer-resolution stage's
  `peerDependenciesWithoutOwn` filter skips peer names also in
  `dependencies` / `optionalDependencies`. `BTreeMap` collection of the
  result by alias could silently drop the optional edge for a name that
  appeared in both. Fix: collect `optionalDependencies` into children
  too, and dedupe peer entries against own deps before appending.

- **Dropped peer edges in `graph_children`**: when a peer pointed at a
  later sibling direct dep (e.g., manifest order `{ react-dom: …,
  react: … }`), the peer's `DepPath` wasn't in `node_dep_paths` yet at
  the time the parent's `graph_children` was built, so the symlink edge
  was silently dropped. Install would then walk react-dom's slot without
  finding react. Add `pending_peer_edges` + a `patch_pending_peer_edges`
  post-pass that runs after every direct dep is walked, with regression
  test.

- **Aliased child peer misfilter**: the "is this peer one of my own
  children?" check compared the peer alias against `tree_node.children`
  keys, but `children` is keyed by install alias while peers can match
  by real name via the dual-keyed `ParentRefs`. Switch both filter
  sites to compare by `NodeId` so an npm-aliased child satisfying a
  peer is correctly classified as internal.

- **`DepPath` newtype consolidation**: `PeerId::DepPath(String)`
  collapsed the depPath brand. Move the `DepPath` newtype from
  `resolving-deps-resolver::dependencies_graph` into the lower-level
  `pacquet-deps-path` crate so the `PeerId` enum carries the branded
  type instead of `String`. `resolving-deps-resolver` re-exports
  `DepPath` from `pacquet_deps_path` to keep existing imports working.

- **Prerelease-tolerant semver match**: `node-semver`'s `Range::satisfies`
  rejects prereleases against non-prerelease comparators (`18.0.0-rc.1`
  vs `^18.0.0` returns `false`). Add a fallback in
  `satisfies_with_prereleases`: if the straight check fails and the
  candidate is a prerelease, retry against the stripped
  `MAJOR.MINOR.PATCH` base. Approximates Yarn's
  `satisfiesWithPrereleases` for the cases pnpm cares about without
  importing the full per-comparator algorithm.

The `tracing::warn!` peer-issue emission flagged by qodo and the
deeper prerelease semantics gap (per-comparator) remain documented
follow-ups; the slice's `resolve_peers.rs` module doc lists what's
intentionally not ported in this PR.

* fix(pacquet): apply cargo fmt to peer-resolution slice

Three sites where the previous edits left non-canonical wrapping:
- `create_peer_dep_graph_hash.rs` test calls now fit one line.
- `lib.rs` re-export ordering (alphabetical).
- `tests.rs` whitespace.

CI Format check is `cargo fmt --all -- --check`; the local pre-push
hook didn't catch these because the edits landed via `Edit` without a
follow-up `cargo fmt`.

* fix(pacquet/resolving-deps-resolver): drop trailing comma rustfmt re-added

`cargo fmt` collapsed the regression test's `assert_eq!` onto one line
and kept the trailing comma, which tripped the same
`perfectionist::macro_trailing_comma` rule that #11774's earlier commit
fixed elsewhere. The lint forbids a trailing comma on single-line macro
calls; rustfmt leaves it alone. Drop the comma to satisfy both.
2026-05-20 22:48:52 +02:00
Zoltan Kochan f807f6d402 feat(pacquet): port JSR specifier parser and wire resolve_jsr (#11772)
* feat(pacquet/resolving-npm-resolver): port JSR specifier parser and wire resolve_jsr

Adds a new `pacquet-resolving-jsr-specifier-parser` crate that ports
`@pnpm/resolving.jsr-specifier-parser` (`parseJsrSpecifier` + `JsrSpec`),
mirroring upstream's `ERR_PNPM_MISSING_JSR_PACKAGE_SCOPE` /
`ERR_PNPM_INVALID_JSR_PACKAGE_NAME` / `ERR_PNPM_INVALID_JSR_SPECIFIER`
error codes 1:1.

Wires the parser into the npm resolver:

- `parse_jsr_specifier_to_registry_package_spec` adapter alongside
  `parse_bare_specifier`, matching upstream's same-file shape in
  `resolving/npm-resolver/src/parseBareSpecifier.ts`.
- `NpmResolver::resolve_impl` detects `jsr:` early and routes to a new
  `resolve_jsr_impl` that picks against `registries['@jsr']` (with the
  `https://npm.jsr.io/` `DEFAULT_REGISTRIES` fallback) and stamps
  `resolved_via = "jsr-registry"` plus `alias = spec.jsr_pkg_name`.
- Extracts a shared `pick_from_registry` helper so npm and JSR paths
  share the picker invocation; `build_resolve_result` now takes a
  `resolved_via` parameter.

Ports the 6 upstream parser test cases and adds adapter + resolver
integration tests covering the JSR happy path, default-tag fallback,
and parser-error propagation.

Ports https://github.com/pnpm/pnpm/blob/05dd45ea82/resolving/jsr-specifier-parser/src/index.ts
and https://github.com/pnpm/pnpm/blob/1627943d2a/resolving/npm-resolver/src/index.ts.

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

* fix(pacquet/resolving-jsr-specifier-parser): treat empty alias as missing in version-only specs

Upstream's `parseJsrSpecifier` guards the version-only branch with the
truthy check `if (!alias)`, so an empty string falls into the
`INVALID_JSR_SPECIFIER` arm. Pacquet's `let Some(alias) = alias else`
form only triggers on `None`, so `Some("")` was instead carried into
`jsr_to_npm_package_name` and surfaced `MISSING_JSR_PACKAGE_SCOPE`.
Filter empties out before the destructure so both sides agree on the
error code.

Also tighten the resolver-level integration test for the unscoped-name
case to assert the upstream-defined error message instead of a generic
"JSR" substring.

Reported by CodeRabbit on https://github.com/pnpm/pnpm/pull/11772.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-20 21:16:01 +02:00
Zoltan Kochan 11a43b15da chore(release): 11.2.1 (#11777) v11.2.1 2026-05-20 16:51:13 +02:00
Zoltan Kochan 2061c55b2a fix(env-installer): mark optional config subdep snapshots with optional: true (#11770)
Match how optional packages are recorded elsewhere in pnpm-lock.yaml so
non-host platform variants pulled in via a config dep's optionalDependencies
aren't treated as required.
2026-05-20 15:40:18 +02:00