11575 Commits

Author SHA1 Message Date
Zoltan Kochan
7c4e421bad test(pnpm): mark Linux-only failures missed by the macOS sweep
The first scoped pacquet-e2e run on Linux turned up two cases the
macOS sweep couldn't observe:

- test/cli.ts `pnpx works`: exercises pnpx, which has no pacquet
  equivalent.
- test/install/supportedArchitectures.ts: the whole matrix is gated
  on Linux (other hosts can't see every architecture), and it passes
  `--reporter=append-only` plus CLI flags pacquet doesn't accept.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 22:35:40 +02:00
Zoltan Kochan
86af5326ff ci(pacquet): scope the e2e run to the pnpm package only
Now that --no-runtime fixed the VM-modules loader, tests actually run.
The next failure mode was real: other workspace packages (e.g.
@pnpm/building.commands) shell out to pnpm in their tests, and
PNPM_E2E_BIN routes those calls through pacquet, which doesn't
implement every command they exercise. Add a dedicated
test-pkgs-pnpm script so the e2e job runs jest only in the pnpm
package and PNPM_E2E_BIN affects nothing else.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 22:12:34 +02:00
Zoltan Kochan
43d8c05697 ci(pacquet): pass --no-runtime to pnpm install
test.yml's working install path uses --no-runtime so pnpm doesn't try
to materialize an executionEnv runtime that conflicts with the one
pnpm/setup pinned. The competing runtime install is the last
discoverable difference between this job and the standard e2e job.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 22:03:39 +02:00
Zoltan Kochan
3a948505f7 ci(pacquet): disable verifyDepsBeforeRun for the test step
pnpm-workspace.yaml sets verifyDepsBeforeRun: install, so every script
gets a re-install side effect before it runs. test.yml's standard e2e
job sets pnpm_config_verify_deps_before_run: false to disable this;
without that override the auto-reinstall seems to leave the loader in
the state that triggers jest's "module is already linked".

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 21:49:41 +02:00
Zoltan Kochan
397a400d29 ci(pacquet): call .test through the test-pkgs-all script chain
pnpm's hidden-script guard fires when \`pn -r .test\` is the top-level
shell command but not when the same call lives inside another script.
Driving the run through \`pn run test-pkgs-all\` (which then chains into
\`pn -r .test\` internally) is the call shape main CI uses successfully.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 21:37:32 +02:00
Zoltan Kochan
5d24e2e7cd ci(pacquet): drop --filter so pn -r can call the hidden .test script
Adding --filter=pnpm re-enabled pnpm's hidden-script guard and broke the
invocation with ERR_PNPM_HIDDEN_SCRIPT. Recursive mode bypasses that
guard on its own; --if-present silently skips workspace packages that
don't define .test, so the unfiltered form does the right thing.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 21:28:56 +02:00
Zoltan Kochan
5a04ddb96b ci(pacquet): invoke .test via pn -r so the call shape matches test.yml
test.yml's standard e2e job drives the same jest config successfully via
\`pn -r .test\`, which executes the hidden \`.test\` script under pnpm's
spawn path. Running jest directly (pnpm exec, ./node_modules/.bin/jest,
etc.) hits the \`module is already linked\` VM-modules regression for
reasons that aren't clear yet but seem tied to that spawn path.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 21:20:10 +02:00
Zoltan Kochan
6dce0847d4 ci(pacquet): run tsgo --build from pnpm/
The repo root has no tsconfig.json — tsgo needs to start from
pnpm/tsconfig.json so it can walk the project references and build
all the workspace packages the e2e tests import.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 21:09:22 +02:00
Zoltan Kochan
1e606e2fdf ci(pacquet): run jest under pnpm's pinned runtime to match the standard e2e job
Earlier attempts to drive the suite under the runner's own Node (or a
separately-installed Node from actions/setup-node) all tripped jest's
"module is already linked" VM-modules regression. The working pattern
test.yml uses for the standard e2e job is to pin a runtime through
pnpm/setup and run jest via `pn` so it inherits that Node — the suite
already passes there on 22.13.0.

PNPM_E2E_BIN points at pacquet, so `dist/pnpm.mjs` is never read; we
can skip the bundle step (and with it the `pnx node@runtime:24.6.0`
collision) and run only `tsgo --build` to produce the lib/ outputs
the tests import from.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 21:02:25 +02:00
Zoltan Kochan
815e8f6d42 ci(pacquet): pass --runInBand so jest's VM-modules loader stays in one process
With --experimental-vm-modules and multiple worker processes the same
module ends up linked twice and every suite errors with "module is
already linked". The base config's maxWorkers: 1 keeps file execution
sequential but still spawns worker processes; --runInBand forces the
main process to do everything, which is what the VM-modules path
expects.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 20:50:37 +02:00
Zoltan Kochan
01fcfccc50 ci(pacquet): resolve the jest shim from the workspace root
pnpm hoists the jest .bin shim to the monorepo root rather than to
pnpm/, so the previous relative path 404'd.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 20:40:14 +02:00
Zoltan Kochan
4564af4c24 ci(pacquet): exec the jest shim directly so PATH Node 22.13.0 runs it
`node $(which jest)` tried to feed Node the shell shim and crashed with
SyntaxError. Calling the shim directly lets it `exec node` against
PATH, which is the pinned 22.13.0 — the same trick that side-steps
pnpm exec's embedded Node 24.x.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 20:30:18 +02:00
Zoltan Kochan
c941f6ab12 ci(pacquet): run jest under the pinned Node, not pnpm exec's embedded one
`pnpm exec jest` was still hitting the "module is already linked"
regression because pnpm spawns under its own embedded Node 24.x
regardless of what `actions/setup-node` puts on PATH. Resolve the jest
script path through pnpm but invoke it directly with `node`, so the
pinned Node 22.13.0 is the one that actually runs the suite.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 20:20:24 +02:00
Zoltan Kochan
e37b694951 ci(pacquet): pin Node 22.13.0 on PATH for the jest step
The runner image's Node 24.x patches trip jest's ESM VM-modules path
with "module is already linked", failing all suites before any test
runs. Bundling still needs the runner's Node so `pnx node@runtime:24.6.0`
can resolve, so the pin is added after `pn compile-only` and only
affects the test step.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 20:10:53 +02:00
Zoltan Kochan
3e138137cb ci(pacquet): use the runner Node so pnx node@runtime:24.6.0 can resolve
Pinning the action's `runtime:` to Node 22.13.0 broke `pn bundle`'s
`pnx node@runtime:24.6.0 bundle.ts` call with
ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND — the on-demand runtime fetch
collides with the pre-installed one. ci.yml's compile job already
demonstrates the working pattern: leave runtime unset, drive
compilation through `pn compile-only`.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 20:00:15 +02:00
Zoltan Kochan
e9f7c1ed76 test(pnpm): cast skipIfPacquet back to typeof test
`test.skip` is declared on jest's `ItBase` interface, which omits the
`.each`, `.only`, etc. accessors that callers reach for. The runtime
delegate exposes them, so a cast keeps the helper's call sites
type-safe without changing behavior. Same for the describe variant.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 19:48:51 +02:00
Zoltan Kochan
8986f536d9 test(pnpm): restore missing test import and tighten skipIfPacquet typing
Fixes the pre-push type-check failures from the previous commit:
- `run.ts` still has one upstream-passing `test(...)` call, so the
  `test` import must stay.
- `skipIfPacquet` / `describeSkipIfPacquet` need explicit
  `typeof test` / `typeof describe` annotations so their generated
  declarations don't reach into `@jest/types` types that aren't
  exported by name.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 19:48:17 +02:00
Zoltan Kochan
19d5d714c7 test(pnpm): run e2e suite against pacquet via PNPM_E2E_BIN
The pnpm CLI e2e tests already exercise the bundled `pnpm.mjs` end-to-end,
so they can serve as a parity probe for the Rust port too. A new
`PNPM_E2E_BIN` env var swaps the spawned binary in `execPnpm`/`execPnpmSync`/
`spawnPnpm`, and a `skipIfPacquet` helper gates the per-test cases that
the port doesn't yet pass. Every failing test in the existing suite was
marked from a local sweep against `target/release/pacquet`; pacquet-CI
gets a new job that builds the binary, builds the pnpm bundle, and runs
jest with the env var set so future regressions surface in CI.

Written by an agent (Claude Code, claude-opus-4-7).
2026-05-21 19:46:53 +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