- Port the shell shim behavior from pnpm/cmd-shim#56 to pacquet.
- Generate `basedir_win` with Cygwin/MSYS/WSL2 handling and use it only when invoking `.exe` runtime branches.
- Preserve POSIX target paths for non-`.exe` runtime branches and add the `.cmd`/`.bat` `/C` runtime fallback.
- Gate MSYS-specific cmd switch escaping behind an `$msys` runtime flag, so MSYS gets `//C` while WSL2 and other shells keep `/C`.
- Bump `@zkochan/cmd-shim` to 9.0.6.
- Replace lockfile env-document stream scanning with a FileHandle read loop that closes deterministically, including split-BOM handling.
- Align pacquet's default `virtualStoreDirMaxLength` with pnpm's Windows default.
- Forward pnpm's effective virtual store max length to delegated pacquet installs through `PNPM_CONFIG_VIRTUAL_STORE_DIR_MAX_LENGTH`, so currently published pacquet versions do not write mismatched `.modules.yaml` on Windows.
* fix: prevent a pinned locked peer provider from leaking to sibling nodes
When the locked-peer-context pinning introduced in pnpm/pnpm#12083 runs for
a node that has no child dependencies, parentPkgs aliases the parent's
object, so writing the pinned provider into it exposed the provider to every
sibling resolved afterwards. Sibling order follows resolution completion
order, so optional peers of siblings resolved nondeterministically and
"pnpm dedupe --check" failed intermittently in CI.
Copy parentPkgs before pinning so the pin stays scoped to the node and its
own subtree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf: copy parentPkgs only before the first pin write
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
## Problem
On Windows, **any failed `pnpm` command hangs 20–46 seconds before exiting.** The error handler (`pnpm/src/errorHandler.ts`) enumerates descendant processes via `pidtree` to terminate them on every error exit. On Windows `pidtree` shells out to `wmic` and, where wmic has been removed, a PowerShell `Get-CimInstance Win32_Process` fallback — a process listing that takes tens of seconds on busy CI runners.
This also broke Windows CI: the `verifyDepsBeforeRun/*` e2e suites are full of intentional-failure assertions (e.g. `pnpm start` with `--config.verify-deps-before-run=error` when deps aren't installed). Each failure paid the ~23 s error-handler tax, so the suite blew past the 70-minute cap. `pnpm install` and success paths never hit the error handler, which is why only failures were slow.
Diagnosed by sampling `process.getActiveResourcesInfo()` during the hang: it showed a lingering `ProcessWrap` (a spawned child), and hooking `child_process.spawn` named it (`wmic` → `powershell … Get-CimInstance Win32_Process`, exiting after ~23–46 s).
## Fix
Race the descendant-process lookup against a 2 s timeout. If it doesn't return in time, skip the kill and exit — `exit()` calls `process.exit`, which abandons the still-running (harmless, read-only) process query instead of blocking on it. The fast path (Unix, fast Windows) is unchanged.
Confirmed on Windows CI: the failing `start` invocations dropped from **~23 s to ~2.7 s**, and `multiProjectWorkspace.ts` went from **716 s to 124 s**.
## Also included
The CI pnpr-binary cache is split into `restore` + an explicit `save` step that runs right after the build, so a failing test step no longer discards the ~20-minute Rust build (the combined `actions/cache` only saved in a post-job step that gets skipped on failure).
## Summary
Adds CI duration tracking for the `pnpm-ci-performance` Bencher project.
Tracked Rust testbeds and benchmarks:
- `pacquet.ubuntu`, `pacquet.windows`, `pacquet.macos` -> `tests.all`
- `pnpr.ubuntu`, `pnpr.windows`, `pnpr.macos` -> `tests.all`
Tracked pnpm testbeds and benchmarks for full test runs:
- `pnpm.ubuntu.node22`, `pnpm.ubuntu.node24`, `pnpm.ubuntu.node26` -> `tests.all`, `tests.cli`
- `pnpm.windows.node22`, `pnpm.windows.node24`, `pnpm.windows.node26` -> `tests.all`, `tests.cli`
The test workflows produce Bencher-compatible JSON artifacts without receiving `BENCHER_API_TOKEN`. A separate `workflow_run` workflow downloads those artifacts only for same-repository runs, validates their metadata, and uploads from trusted workflow code using the existing `BENCHER_API_TOKEN` secret. The pnpm CLI e2e duration is extracted from `pnpm run --report-summary` output during the same full-test execution, so the CLI e2e suite is not run a second time.
* feat(installing): delegate resolution to pacquet >= 0.11 when configured
When pacquet is declared in configDependencies, pnpm previously always
ran it with --frozen-lockfile (pnpm resolved, pacquet materialized). If
the installed pacquet is >= 0.11 it ships its own resolver, so a
non-frozen plain install on the default isolated linker is now delegated
end-to-end: pacquet resolves, writes pnpm-lock.yaml, and materializes in
a single pass. Older pacquet keeps the resolve-then-materialize split,
and add/update/remove still resolve in pnpm.
* test(pnpm): cover pacquet 0.11 resolution delegation end-to-end
Bump the e2e pacquet pin to 0.11.0 (published under both pacquet and
@pnpm/pacquet) and add tests for the resolve path, the materialize-only
fallback (pinned to 0.2.14), and the scoped alias. Un-skip the add/update
tests now that pacquet 0.11 writes a compatible .modules.yaml, and fix
the update test (is-positive has no v4; update from 1.0.0 instead).
Also preserve the configDependencies env document when pacquet resolves:
pacquet rewrites pnpm-lock.yaml without the leading env YAML doc, which
dropped configDependencies and broke the next --frozen-lockfile install.
Capture it before delegating and restore it after.
* fix(installing): restore configDependencies env document even when pacquet fails
On the pacquet resolve-delegation path the configDependencies env document
was only restored after a successful pacquet run. A non-zero exit could
leave a rewritten pnpm-lock.yaml without it, breaking the next
--frozen-lockfile install's config-deps freshness gate. Restore it in a
finally block, swallowing (and warning on) any restore error so it cannot
mask the original pacquet failure.
* fix(installing): require pacquet 0.11.7 for resolving installs
* fix(installing): skip pacquet in lockfile check mode
* fix(installing): harden pacquet lockfile handoff
* fix(installing): preserve policy handling with pacquet
* fix(installing): skip pacquet when lockfile is disabled
* fix(installing): skip pacquet with branch lockfiles
* fix(installing.commands): key selectProjectByDir graph by project.rootDir
`selectProjectByDir` constructs a single-entry `ProjectsGraph` for the
non-workspace install path. It was using `searchedDir` (`opts.dir`) as
the key, but downstream `recursive()` builds `manifestsByPath` from the
projects array (keyed by `project.rootDir`) and then looks up entries
via `manifestsByPath[rootDir]` where `rootDir` is drawn from
`Object.keys(selectedProjectsGraph)`. When `opts.dir` and
`project.rootDir` differ in platform-normalized form (most often on
Windows due to drive-letter casing), the lookup falls through as
`undefined` and `pnpm add <pkg>` crashes with:
Cannot destructure property 'manifest' of 'manifestsByPath[rootDir]' as it is undefined
Pin the graph key to `project.rootDir` in both `installing/commands/src/installDeps.ts`
and `installing/commands/src/import/index.ts`, so the keys stay in sync
with `manifestsByPath`. Closes https://github.com/pnpm/pnpm/issues/12379
Written by an agent (Claude Code, claude-opus-4-7).
* docs: remove redundant comments
* test(installing.commands): cover project graph keying
* Revert "test(installing.commands): cover project graph keying"
This reverts commit 426fae9434.
* test(installing.commands): cover add with mismatched project dir
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* fix: preserve user-defined npm_config_* env vars in lifecycle scripts
* fix: use released `@pnpm/npm-lifecycle` and port npm_config_* filter to pacquet
Pin the catalog to the released `@pnpm/npm-lifecycle` ^1100.0.0 instead of a
mutable PR-head ref, regenerating the lockfile to the immutable registry
tarball.
Port the upstream env filter to pacquet's make_env so user-defined
npm_config_* vars (e.g. npm_config_platform_arch) survive lifecycle scripts
while (npm|pnpm)_config_* auth keys are still stripped, matching
`@pnpm/npm-lifecycle` 9e2ac78148.
Harden the new TS test to save/restore npm_config_platform_arch.
* test(executor): restore env vars to pre-test value in lifecycle EnvGuard
The guard removed the seeded var unconditionally on drop, which would
discard any value the process env already had. Capture the original via
var_os and restore it (or remove only when originally absent).
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
When running from the standalone executable, `pnpm setup` installs pnpm
via `pnpm add -g file:<dir>`. The shipped `@pnpm/exe` package.json carries
preinstall/prepare scripts, which triggered a build-approval prompt for
pnpm's own install. pnpm links the platform-specific binary itself, so
these scripts are unnecessary (and unrunnable on a Node-less host); pass
--ignore-scripts to skip them.
Closes https://github.com/pnpm/pnpm/issues/12377
The 'runs checks in parallel' test raced three real setTimeout delays
(10/20/30ms) and asserted their exact completion order. Timer scheduling
jitter on Windows CI runners reordered the sub-50ms timers, so the suite
flaked and failed every Windows test job.
Replace the timing race with a start-barrier: each hook blocks until all
hooks have started. This only completes if the checks run concurrently --
were they awaited one at a time, the first hook would wait forever for
siblings that never start. No timers, no ordering assumptions.
`pnpm publish` was ignoring `strictSsl: false` from `.npmrc` /
`pnpm-workspace.yaml` because `createPublishOptions` never included
`strictSSL` in the options object passed to `libnpmpublish`.
`npm-registry-fetch` (used internally by `libnpmpublish`) defaults
`strictSSL` to `true`, so the flag had to be forwarded explicitly.
Fixespnpm/pnpm#12012.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
pnpm can now use different auth tokens for different package scopes, even when those scopes use the same registry URL.
Previously, auth was selected only by registry URL. If `@org-a` and `@org-b` both used `https://npm.pkg.github.com/`, they had to share the same token. This caused problems for registries that issue tokens per organization or per scope.
Configure a scope-specific token by adding the package scope after the registry URL in the auth key:
```ini
@org-a:registry=https://npm.pkg.github.com/
@org-b:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:@org-a:_authToken=${ORG_A_TOKEN}
//npm.pkg.github.com/:@org-b:_authToken=${ORG_B_TOKEN}
//npm.pkg.github.com/:_authToken=${FALLBACK_TOKEN}
```
`pnpm login --registry=https://npm.pkg.github.com --scope=@org-a` writes the token to the same scope-specific auth key.
When installing or publishing `@org-a/*`, pnpm uses `ORG_A_TOKEN`. For `@org-b/*`, pnpm uses `ORG_B_TOKEN`. Packages without a matching scope continue to use the registry-wide fallback token.
## Summary
Fixes#12171.
pacquet stores executable CAFS entries under paths ending in `-exec`, but the copy fallback tier in `link_file.rs` materialized them without the executable bit. When hardlink/reflink isn't available and the install falls back to `fs::copy` (e.g. overlayfs on CI), a native binary such as `@esbuild/linux-x64/bin/esbuild` lands at `0o644` and fails to spawn with `EACCES`.
This routes all copy-tier imports through one `copy_file` helper. On Unix, when the CAS source path ends with `-exec`, it OR-s the exec bits onto the copied file, mirroring `git-fetcher`'s existing `materialize_into`. Non-executable files are left exactly as `fs::copy` produced them.
#12177 (closed) took the same direction but re-read and re-applied the source's full mode to every copied file. This version only touches files the store marked executable (`-exec` suffix), so it never widens a restrictive mode (e.g. `0o600` → `0o711`) and adds no `set_permissions` syscall on the non-exec majority. Rather than duplicate that logic, the `-exec` check now lives in a shared `pacquet_fs::file_mode::cas_path_is_executable` that both copy paths call, replacing the private copy in `cas_io.rs`.
This is a pacquet-only bug. pnpm preserves the exec bit on its own copy path, so no pnpm-side change is needed.
* **New Features**
* During installation, Pacquet can now optionally synchronize pnpm “package manager dependencies” from the workspace `package.json` policy, including when frozen-lockfile mode is enabled.
* **Improvements**
* More accurate lockfile generation and resolution for optional dependencies, including platform metadata (such as `libc`) and package-manager metadata.
* Enhanced resolver mirror support, including pnpm-style JSONL mirrors and filtered full-metadata caching/persistence.
* **Bug Fixes**
* Improved lockfile pruning/traversal to generate smaller lockfiles without missing optional dependency reachability.
* chore: add git hook rejecting bare `@mentions` in commit messages
* fix(husky): align bare-mention boundary with GitHub linkification rules
* fix(husky): keep mention boundaries intact when stripping code spans
* fix(husky): ignore the verbose-commit diff below the scissors line
* refactor(husky): split bare-mention hook into small single-purpose functions
* fix(husky): exempt tilde-fenced code blocks from mention scanning
* fix(husky): trim trailing punctuation from the reported scoped handle
* refactor(husky): detect bare mentions by scanning, not regex
Replace the single packed detection regex with a plain character scan and
small predicate functions: for each `@`, exempt it when it sits inside
backticks (odd backtick count before it), when it is not followed by an
ASCII letter/digit, or when it is preceded by one (email-like). This is
easier to read and reason about than the regex.
Side effect: the `~~~` tilde-fence special case is gone, so a mention inside
a `~~~` block is now flagged. Backtick code spans and fences are still
exempt.
* docs(husky): note readHandle precondition that the char after @ is alphanumeric
* refactor(husky): spell out abbreviated identifiers in the mention hook
---------
Co-authored-by: Claude <noreply@anthropic.com>
test/install/deepRecursive.ts resolves @teambit/bit's enormous circular and
peer-dependency graph. Measured in a CI-faithful container (Linux, Node
22.13.0, amd64, default ~4 GB heap) it peaks at ~3.6 GB — it fits the default
heap on its own, but not with the memory the other deps-installer test files
leave behind in the same jest process (the --experimental-vm-modules module
registry is not reclaimed between files). That overflow is the
"FATAL ERROR: Reached heap limit" the CI suite hit.
Run deepRecursive in a dedicated jest process (.test:heavy) so it gets the
whole default heap to itself, and run the rest (.test:rest) in a separate
process with it excluded via a negative-lookahead path pattern. The two runs
cover every test file exactly once.
This makes the earlier OOM workarounds unnecessary, so they are reverted:
- the 5-way sharding of the suite (deepRecursive was the sole culprit; the
remaining files are a subset of what historically ran in one process), and
- the workerIdleMemoryLimit in the with-registry jest preset.
No global heap bump: every run stays within Node's default ~4 GB, matching
the budget pnpm has in production.
* fix(pnpr): pass batch_publish test request bodies by reference
The put_json/put_json_with_token test helpers took the JSON body by
value but only borrowed it for serde_json::to_vec, tripping clippy's
needless_pass_by_value under --all-targets. Take &Value instead, which
also drops an unnecessary body.clone() at one call site.
* ci: run clippy as a single-OS job and add it to the pre-push hook
Clippy was a step inside the three-OS Lint-and-Test matrix, so it ran
once per OS even though it lints the same platform-independent source
each time. Move it to its own ubuntu-only job, mirroring the existing
single-OS doc, format, and dylint jobs (platform-gated cfg blocks are
still type-checked per-OS by the test build).
It was also missing from pacquet/scripts/pre-push-rust.sh, so a clippy
lint that only fires under --all-targets — like the one that just
reached main — slipped past local pushes and surfaced only in CI. Add
the same --all-targets workspace clippy gate to the hook.
* feat(publish): add --batch flag to publish all packages in a single request
When publishing recursively, the new opt-in --batch flag packs every
selected package first and sends them all to the registry in one
"PUT /-/v1/multi-publish" request per target registry, instead of one
request per package. The endpoint is not part of the standard npm
registry API; registries that lack it are reported with
ERR_PNPM_MULTI_PUBLISH_UNSUPPORTED.
pnpr implements the endpoint with all-or-nothing semantics: every
document is validated (name, publish policy, attachment integrity) and
every tarball is fully written to a tmp slot before anything becomes
visible, so a batch that fails midway leaves no new versions behind.
The single-package publish handler was refactored into shared
validate/stage/commit steps, and package-lock stripes are acquired in
sorted order so overlapping batches cannot deadlock.
* refactor(publish): namespace the multi-publish endpoint under /-/pnpm/v1/
Vendor-namespace the batch publish endpoint as /-/pnpm/v1/multi-publish,
mirroring how the npm client keeps its registry extensions under
/-/npm/v1/. The unprefixed /-/v1/ namespace is effectively owned by the
npm registry API (login, search, tokens), so a future npm endpoint at
the same path could collide with different semantics. The pnpm prefix
makes the path unambiguously a pnpm-client extension while staying
server-implementation-neutral.
* refactor(publish): rename the batch endpoint to /-/pnpm/v1/publish
The endpoint is not inherently about multiple packages — a single
package is just a batch of one — so name it after the operation rather
than the cardinality. The unsupported-registry error is renamed to
ERR_PNPM_BATCH_PUBLISH_UNSUPPORTED to match the other BATCH_PUBLISH_*
error codes.
* feat(pnpr): crash-atomic publish commits via a journal
A publish (single-package or batch) is applied in several non-atomic
steps — one rename/upload per tarball, one packument write per package
— so a crash mid-apply could leave a batch partially published. Before
anything is promoted, the full intent (merged packument bytes + staged
tmp-file locations) is now persisted under .pnpr-journal/<txn>/ and
sealed with a single atomic rename of the commit marker. Startup
recovery rolls sealed transactions forward (every apply step is
idempotent, and the packument is re-merged into the current on-disk
state so versions published between a failed apply and the restart
survive) and rolls unsealed ones back, so a publish is either fully
visible or fully absent.
* test(pnpr): pin canonicalization of scoped dist.tarball URLs on serve
libnpmpublish-based clients submit dist.tarball with the scoped
filename (.../-/@scope/name-1.0.0.tgz). The registry never serves that
string verbatim: rewrite_dist_tarball rebuilds the URL from the
basename, so consumers always see the routable 4-segment form. The
batch publish scoped test now submits the real client wire form and
asserts the served packument exposes the canonical URL.
* test: align getConfig env-var warning test with request destination blocking
Project-level .npmrc request destinations (registry=, proxy keys) no
longer go through env expansion at all — they are dropped with a
dedicated warning — so registry=${VAR} can't produce the generic
'Failed to replace env in config' warning anymore. Exercise the
env-replace warning through cafile= (still expanded) and pin the
request-destination ignore warning in its own test.
* fix(pnpr): harden crash-atomic publish commit against partial states
Two reliability fixes from PR review:
- Journal recovery treated any I/O error probing the commit marker as
"unsealed" (`try_exists(...).unwrap_or(false)`), so a transient error
could roll back an already-committed transaction and delete its staged
tarballs. Propagate the error instead, so startup fails loudly rather
than risk losing a sealed publish.
- After sealing, the commit loop applied packages with `?`; a mid-loop
failure returned an error while earlier packages were already visible,
leaving a running server partially published until the next restart's
recovery. On apply failure, complete the sealed transaction immediately
via the same idempotent roll-forward, falling back to the original
error with startup recovery as the final backstop.
* fix(pnpr): fail recovery loudly when a staged tarball can't be probed
roll_forward treated any fs::try_exists error on a staged tarball as
"missing", so a transient I/O error would skip promotion, still write the
packument, and delete the journal entry — advertising a tarball with
nothing on disk and no journal state left to retry from. Propagate the
error instead, mirroring the commit-marker probe, so recovery aborts and
the transaction survives for a later attempt.
* test: fix env-var warning assertion in the cafile getConfig test
cafile=${ENV_VAR_123} asserted the registry request-destination warning,
which is only emitted for registry/proxy URLs; cafile is still
env-expanded, so an unresolved placeholder surfaces the generic
"Failed to replace env in config" warning. Assert that instead and
retitle the test (the request-destination case has its own test).
* fix(pnpr): serialize package deletes with the same package lock
delete_package and delete_tarball mutated package storage without taking
the per-package lock that every publish and dist-tag path holds, so a
same-package DELETE could race a stage-and-commit and remove the package
or a tarball mid-write, leaving the on-disk state dependent on filesystem
timing. Acquire the lock before the removal, completing the same-package
serialization guarantee.
Also bind the batch-publish test stub URL to 127.0.0.1 to match the
listener, avoiding intermittent IPv6 connection failures.
## Summary
- detect catalog drift between `pnpm-workspace.yaml` and the lockfile `catalogs:` snapshot
- record and compare catalogs in pacquet's workspace-state fast path so plain `pacquet install` does not skip after catalog edits
- add lockfile, repeat-install, and CLI regression coverage for catalog changes
The `with-registry` Jest preset sets `maxWorkers: 1` to avoid the dist-tag
races that concurrent registry tests would cause. With no
`workerIdleMemoryLimit`, Jest's `shouldRunInBand` collapses `maxWorkers: 1`
to in-band execution, running every test file in the main process. Under
`--experimental-vm-modules` the VM module registry is never released
between files, so the process climbs to Node's default ~4 GB old-space
ceiling and dies with a heap out-of-memory FATAL ERROR (sharding the file
selection doesn't help — each shard still runs all its files in one
long-lived in-band process).
Setting `workerIdleMemoryLimit` both forces Jest to use a (single, serial)
worker instead of running in-band and recycles that worker once its heap
crosses the limit, reclaiming the leaked memory between files.
## Summary
- Match pacquet peer-resolution and lockfile output to pnpm for transitive optional peer variants.
- Apply pnpm's Yarn compatibility package extensions in pacquet, with `ignoreCompatibilityDb` support.
- Add regression coverage on both the pnpm resolver test and pacquet resolver/install paths.
Fixespnpm/pnpm#12330.
* fix: keep the path of git-hosted tarball resolutions in the lockfile
* test(lockfile): cover git-hosted tarball path preservation in to_lockfile_form
Adds regression tests guarding that to_lockfile_form keeps the
TarballResolution.path of git-hosted subdirectory tarballs in both the
default and include_tarball_url branches, mirroring the TypeScript
toLockfileResolution tests added for pnpm/pnpm#12304.
* refactor(lockfile): simplify toLockfileResolution to a single kept-URL return
Invert the branching so the canonical-registry case is the early return and
the kept-URL resolution (integrity + tarball + optional gitHosted/path) is the
single fall-through. This removes the keepTarballUrl closure and the
preservingGitHosted helper; the URL-reconstruction check moves into
isCanonicalRegistryTarballUrl. No behavior change.
* refactor(lockfile): mirror the single kept-URL return in to_lockfile_form
Apply the same inversion as the TypeScript toLockfileResolution: the
canonical-registry case becomes the early return and the kept-URL resolution
is the single fall-through, removing the keep_url closure. The URL-match check
moves into is_canonical_registry_tarball_url. No behavior change; the three
to_lockfile_form tests still pass.
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
* perf(cli): keep the repeat-install fast path off the lockfile parse and thread spawns
The "Already up to date" short-circuit decides from manifest mtimes
alone (mirroring upstream checkDepsStatus, which never reads the wanted
lockfile), yet pacquet parsed pnpm-lock.yaml eagerly in State::init
before the check ran — a multi-millisecond YAML parse on every no-op
install, scaling with lockfile size (babylon's 720 KB lockfile dominated
its repeat-install wall time).
pnpm-lock.yaml now loads through a LazyLockfile (OnceLock-backed) that
install forces only after the fast path has passed on the run; add /
update / remove / outdated / the pnpr path force it up front, keeping
their behavior unchanged. The repeat-install regenerate branch probes
for the file's existence instead of its parsed contents, so the
fast path stays mtime-cheap.
The rayon global pool is likewise no longer built eagerly at startup:
the worker count is published via RAYON_NUM_THREADS (set in fn main
while the process is still single-threaded) so the pool spawns lazily
on first parallel use — commands that never reach a parallel phase no
longer pay 2×CPUs thread spawns.
A corrupt lockfile now surfaces its parse error when the install
actually reads the file; an up-to-date project with an unreadable
lockfile reports "Already up to date" exactly as pnpm does.
* perf(cli): finish up-to-date installs before building the async runtime
A plain `pacquet install` that is already up to date now completes on
the main thread, before the tokio runtime, the rayon pool, the HTTP
client, or any install state exists. The new
`install_already_up_to_date` twin of the repeat-install short-circuit
reuses the exact same workspace discovery and
`check_optimistic_repeat_install` inputs as `Install::run`, and the
CLI invokes it from the (now synchronous) `main` after clap parsing.
Gates mirror everything that would make the full path behave
differently: `--frozen-lockfile` / `--lockfile-only`, a configured
pnpr server (that path never runs the optimistic check), `--recursive`
/ `--filter`, config dependencies, and pnpmfile updateConfig hooks
(both can mutate the config the check compares against). Any gate or
error falls through to the full install path, which re-runs the check
and reproduces failures with their established error shape; the
"Already up to date" + summary emissions are byte-identical.
Repeat-install instruction count on the vue fixture drops from ~203M
to ~41M retired instructions — within a rounding error of the
`pacquet --version` floor (~39M).
* perf(resolving): renew metadata-mirror freshness on 304 Not Modified
The minimumReleaseAge freshness shortcut treats a metadata mirror
younger than the cutoff as authoritative and resolves without touching
the network. But a 304 revalidation never rewrote the mirror file, so
its mtime froze at the last 200 response: once a cached packument grew
older than minimumReleaseAge (24h by default), every subsequent install
re-validated every package against the registry, forever.
A 304 proves the cached packument equals the registry's current
document, so the validation clock legitimately restarts at the
response: bump the mirror's mtime to now (fire-and-forget — a
read-only cache dir only costs the next install another conditional
request). Applied to both stacks: pnpm's pickPackage notModified
branch and pacquet's fetch_full_metadata_cached 304 path.
On a vue-fixture install with a stale cache, the second warm resolve
drops from ~2s (520 conditional requests) to ~250ms (zero requests).
* style(resolving): use clippy's preferred Duration units in the 304 mtime test
CI clippy denies duration_suboptimal_units; from_hours / from_mins
replace the hand-multiplied from_secs values.
* style(package-manager): use clippy's preferred Duration unit in the sync fast-path test
Same duration_suboptimal_units deny as the previous commit, one site
CI's clippy surfaced after it stopped at the first failing crate.
* fix(lockfile): address review — dir-addressed LazyLockfile, read-open 304 touch
LazyLockfile resolved pnpm-lock.yaml against the process cwd while the
CLI honours a canonicalized --dir without chdir, so the deferred load
and the existence probe could consult a different lockfile than the
rest of the install (which derives lockfile_path from the manifest's
directory). The lazy handle now carries the manifest's directory and
loads via load_wanted_from_dir; lockfile-disabled config gets an
explicit disabled() constructor. The pre-runtime fast path builds its
handle from the same directory, keeping verdict parity with
Install::run.
renew_mirror_freshness opened the mirror with append just to bump the
mtime; set_modified only needs a file handle plus ownership (futimens
semantics), so a plain read-open also covers mirrors whose mode
dropped write permission.
* test(integrated-benchmark): compare best-of-N samples in the slow-start proxy test
The test raced two single wall-clock samples, and a loaded CI runner
can inflate the ramped-vs-flat comparison in either direction (macOS
runner measured flat 318ms vs ramped 304ms against a ~66ms model).
Scheduler stalls only ever inflate a sample, while slow start's ramp
overhead is structural and survives in every sample — so the minimum
of several runs per side is the noise-resistant estimator.
* chore(deps): bump esbuild to 0.28.1 to clear GHSA-gv7w-rqvm-qjhr
The new advisory (install-module RCE via NPM_CONFIG_REGISTRY,
patched in 0.28.1) fails the audit gate. 0.28.1 was published within
the minimumReleaseAge window, so the patched version is excluded from
the age gate — the same mechanism pnpm audit --fix uses — including
its '@esbuild/*' platform packages, whose versions move in lockstep
with the root package.
* fix(lockfile): make the unloaded presence probe match the loader's absence rules
The loader treats an empty file and an env-only combined document as
an absent wanted lockfile (Ok(None)), but is_loaded_or_on_disk probed
bare file existence, so the repeat-install path could skip restoring a
semantically-missing pnpm-lock.yaml. The probe now reads the file and
checks the main document is non-empty (Lockfile::wanted_exists_in_dir)
— the loader's exact absence rules, still without the YAML parse.
* fix(cli): build the rayon pool after the fast-path gate instead of injecting env
Publishing the worker count through RAYON_NUM_THREADS leaked the
variable into every child process the install spawns — lifecycle
scripts, node probes, git — and pnpm exposes no such variable to
scripts. Build the global pool with ThreadPoolBuilder again, but only
once the repeat-install fast path has declined: real installs pay
exactly the cost they always did, the no-op path still spawns no
workers, and the process environment stays untouched (which also
drops the unsafe set_var and its single-threaded contract).
* fix(lockfile): treat only NotFound as absence in the presence probe
A permission or I/O failure reading pnpm-lock.yaml reported the file
as absent, which would send the repeat-install path into the
regenerate-on-missing branch — overwriting an existing lockfile it
merely could not read. Only NotFound counts as absent now; any other
read failure reports presence, and the real load surfaces the
underlying error when the contents are actually needed.
* fix(resolving): open the mirror write-capable for the 304 touch, read-only as fallback
Windows' set_modified requires write-attributes access on the handle,
so the read-only open silently failed there (caught by the Windows CI
run of a_304_renews_the_mirror_mtime). Append-mode open carries that
access; the read-only fallback still covers Unix mirrors whose mode
dropped write permission, where timestamp syscalls need ownership
rather than write access.
* fix(package-manager): never short-circuit partial installs as already up to date
add and remove mutate the manifest in memory and persist it only after
Install::run returns, so the on-disk mtimes the optimistic
repeat-install check reads still describe the pre-mutation project.
With a fresh workspace state, `pacquet add X` right after a clean
install reported "Already up to date", skipped the entire install,
and then saved a package.json declaring a dependency that was never
resolved, lockfiled, or materialized (self-healing on the next run,
which sees the newer manifest mtime).
Gate the short-circuit on is_full_install, mirroring upstream
installDeps calling checkDepsStatus only for the plain-install
mutation, never for installSome / uninstallSome. The new
partial_install_disables_optimistic_short_circuit test fails without
the gate.
The bug predates this PR (the KeepAll gate has carried add since the
optimistic path landed) — surfaced by CodeRabbit review on
pnpm/pnpm#12364.
* chore(rust/clippy): enable undocumented_unsafe_blocks
Move the SAFETY rationale out of the EnvGuard doc comment and onto the
two env-var unsafe blocks it actually justifies, so every unsafe block
in the workspace now carries its own // SAFETY: comment.
* chore(rust/clippy): enable unnecessary_safety_comment
Reword a comment in local_tracing that used the // SAFETY: prefix to
justify an expect() on safe code; the prefix is reserved for unsafe
blocks.
* chore(rust/clippy): enable todo
No occurrences in the workspace; the lint keeps stray todo!() markers
out of committed code from now on.
* chore(rust/clippy): enable unimplemented
No occurrences in the workspace; the lint keeps stray unimplemented!()
markers out of committed code from now on.
* chore(rust/clippy): enable exit
The one deliberate process exit — dlx propagating the spawned command's
exit status, matching pnpm — now carries an #[expect] stating that;
any new std::process::exit call site has to justify itself the same
way.
* chore(rust/clippy): enable infinite_loop
No occurrences in the workspace; a loop that can never terminate must
return ! from now on.
* chore(rust/clippy): enable mem_forget
The overrides test helper leaked its TempDir via mem::forget to keep
the fixture on disk; TempDir::keep() is the purpose-built API for
that. The two registry-mock forget(mock_instance) calls stay — the
leak is how the shared mock-registry process survives the spawning
test process — but now carry #[expect] so the intent is
machine-checked.
* chore(rust/clippy): enable get_unwrap
Replace .get(...).unwrap() / .get_mut(...).unwrap() with indexing
across the test suites (machine-applicable clippy fixes): the panic is
the same, but the intent is clearer and the panic message names the
missing key instead of 'unwrap on None'.
* chore(rust/clippy): enable unused_result_ok
Two best-effort calls in tests discarded their Result with .ok();
spell the discard out with let _ = instead, so .ok() is reserved for
actually consuming the Option.
* chore(rust/clippy): enable pathbuf_init_then_push
resolve_path built its joined path with to_path_buf + push; Path::join
expresses the same thing in one allocation-aware call.
* chore(rust/clippy): enable string_add
The phase-event fixture concatenated string literals at runtime with
String + &str; concat! assembles the same fixture at compile time.
* chore(rust/clippy): enable verbose_file_reads
No occurrences in the workspace; whole-file reads go through fs::read
/ fs::read_to_string rather than manual open + read_to_end loops from
now on.
* chore(rust/clippy): enable allow_attributes
Convert every #[allow] in the workspace to #[expect]. The conversion
immediately paid for itself by surfacing four suppressions whose lint
no longer fires:
- resolve_peers: a too_many_arguments expect on a three-argument
method (stale since a refactor) - removed.
- resolve_peers: two dead_code expects on MissingPeerInfo fields the
compiler considers used - removed.
- tarball: fetch_and_extract_zip_once stacked two copies of the same
too_many_arguments suppression - deduplicated.
- ensure_file: a cfg_attr(windows, allow(unused)) on a mode parameter
that IS used on Windows (via verify_or_rewrite) - removed.
The remaining windows-side cfg_attr suppressions become expect with a
reason, and fire correctly there (the parameters are genuinely unused
on that cfg).
* chore(rust/clippy): enable allow_attributes_without_reason
Every #[expect] in the workspace already carries a reason; the lint
locks the convention in.
* chore(rust/clippy): enable negative_feature_names
No occurrences in the workspace; cargo features stay additive
(no no-std / not-x style names) from now on.
* chore(rust/clippy): enable redundant_feature_names
No occurrences in the workspace; feature names won't restate that they
are features (use-x / with-x prefixes and suffixes).
* chore(rust/clippy): enable wildcard_dependencies
No occurrences in the workspace; every dependency keeps a real version
requirement instead of "*".
* chore(rust/clippy): drop allow_attributes and allow_attributes_without_reason
perfectionist is adding better-targeted replacements for both, so leave
the clippy versions off to avoid duplicate diagnostics. The #[expect]
conversions they prompted stay — they are valid without the lints and
already removed four stale suppressions.
* chore(rust/clippy): trim redundant lint comments
The enabled lints' names already say what they enforce, so the inline
notes and per-theme section headers restated the obvious. Drop them to
match the convention of the surrounding activations, keeping only the
two category headers that carry non-obvious context.
* chore(rust/clippy): drop get_unwrap
The lint only rewrites `x.get(i).unwrap()` to `x[i]`, erasing the word
"unwrap" from panicking call sites and making them harder to grep for.
No safety benefit, so revert the enablement and all of its rewrites.
This reverts commit f274ad8.
* chore(rust/clippy): revert allow_attributes enablement and its code changes
Undo the #[allow] -> #[expect] conversions from bde9865 and restore the
suppressions that conversion dropped. perfectionist is adding a
better-targeted replacement; notably it exempts `allow` inside
`cfg_attr`, so the #[cfg_attr(windows, allow(unused))] attributes are
restored as-is. The clippy lints themselves were already removed.
This reverts the code changes of commit bde9865.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(deps-resolver): write the owner's missing-peer record once per ownership generation
The owner-keyed first-walk record was overwritten on every owner pass,
so once the owning importer hoisted a peer into its own scope (e.g.
typanion under @yarnpkg/core, owned by hooks/read-package-hook), every
other importer's hoist of that peer was suppressed — upstream's
once-per-generation missingPeersOfChildren promise keeps the owner
walk's initial misses visible instead, and each importer hoists its
own copy. The record now stores the recording owner: the owning
importer writes once per ownership generation (its later post-hoist
passes never refresh it), an ownership change records afresh, and an
owner's report replaces a non-owner's provisional one.
Restores the (typanion@3.14.0) / (@babel/types@7.29.7) suffixes that
regressed with the deterministic-owner port; whole-monorepo lockfile
diff vs fresh pnpm 11.6.0: 11 -> 3 changed lines.
Part of https://github.com/pnpm/pnpm/issues/12266.
* fix(deps-resolver): keep a cycle's closing edge through the first re-entry
pacquet's cycle break dropped the edge at the first re-entry of an
ancestor package, so a snapshot like arraybuffer.prototype.slice's
lost its cycle-closing es-abstract dependency line. pnpm's buildTree
gate (parentIdsContainSequence) only drops a direct self-edge and the
second lap of the full parent…child sequence: the first re-entry's
node exists with its own back-edge pruned, and the
previously-resolved-children merge in the peer pass — already ported,
but dead code until now — restores the pruned children on the
repeated node's graph entry. Ported the gate to the seed walk, the
lockfile-reuse walk, and the lazy realization in the peer pass.
Whole-monorepo lockfile diff vs fresh pnpm 11.6.0: 3 → 0 — the real
lockfile document is now byte-identical, deterministic across runs.
Part of https://github.com/pnpm/pnpm/issues/12266.
## Summary
Two parity changes for pacquet's resolver, continuing https://github.com/pnpm/pnpm/issues/12266. Measured on the monorepo (fresh state, `install --lockfile-only`, back-to-back vs **pnpm 11.6.0**), the real-lockfile document diff drops from **128 to 5 changed lines** (re-measured after rebasing over #12361/#12362: **132 → 11**, where 8 of the 11 are a divergence the pacquet side of #12362 itself introduced — see the analysis on pnpm/pnpm#12266 — and 3 are the known cycle-closing-edge gap).
### 1. Per-level preferred-version fold
pnpm extends the preferred-versions map per resolution level: after a package's direct dependencies settle, their `(name, version)` pairs join the map the *children's* subtree resolutions pick against ([resolveDependencies.ts#L717-L746](https://github.com/pnpm/pnpm/blob/ce9c096e8e/installing/deps-resolver/src/resolveDependencies.ts#L717-L746)). So `signed-varint`'s `varint@~5.0.0` dedupes to the `varint@5.0.0` its parent pinned as a sibling instead of drifting to `5.0.2`. pacquet picked against a static seed only; besides `varint`/`es-abstract`, this turned out to drive the remaining `jest`/`@types/node` duplicate variants too.
- The walk resolves a whole sibling level before any child subtree starts (upstream's postponed-resolution barrier): `resolve_node` splits into `resolve_node_seed` + `walk_node_children`.
- Each level layers its versions onto a new `PreferredVersionsOverlay` (O(1) `Arc`-chained layers in `resolver-base`); the npm picker folds the per-name view in as plain `version` selectors at both registry seams.
- The overlay's per-name view joins the per-wanted dedup cache key; lockfile-reuse subtrees keep the no-overlay path (exact pins).
### 2. Hoist rounds across all importers (deterministic barrier, same logic as pnpm)
pnpm resolves **every importer's initial wave before any peer hoist**, then repeats global hoist rounds (per round: each importer's required-peer loop to a fixpoint, then one optional-peer hoist) until no importer hoists ([resolveDependencies.ts#L335-L445](https://github.com/pnpm/pnpm/blob/ce9c096e8e/installing/deps-resolver/src/resolveDependencies.ts#L335-L445)). pacquet ran each importer's whole hoist loop before the next importer's initial wave, so an early importer's optional-peer pick couldn't see versions a later importer resolves — `@cyclonedx`'s `spdx-expression-parse` hoisted `3.0.1` where pnpm's barrier-visible map picks `4.0.0`. `resolve_importer_with_workspace` is now an `ImporterHoistState` (`init` / `run_required_round` / `hoist_optional_round`) driven by `resolve_workspace` in upstream's exact phase order. Both implementations are deterministic here; the rule is identical.
## Verification
- New regression test `child_resolution_prefers_parent_level_sibling_versions` (fails with the fold disabled) + full `resolving-*`, `package-manager`, `cli` suites: 1,242 tests pass; clippy `--deny warnings`, rustfmt, typos clean.
- Whole-monorepo diff vs fresh pnpm 11.6.0: 128 → 5 changed lines; consecutive pacquet runs byte-identical.
## Summary
- Make shared package child resolution deterministic by choosing the owner by depth, importer order, and parent path instead of async completion timing.
- Keep non-owner and stale occurrences lazy while reusing the current owner children and missing-peer context.
- Port the same behavior to pacquet and add TypeScript plus Rust regression coverage.
## Verification
- `pnpm --filter @pnpm/installing.deps-resolver test test/resolveDependencyTree.test.ts`
- `cargo test -p pacquet-resolving-deps-resolver`
- Pre-push hook: TypeScript typecheck, pnpm bundle, lint, spellcheck, meta lint, cargo fmt, cargo doc, cargo dylint, taplo format check
Fixespnpm/pnpm#12358
* feat(pacquet): hand custom resolvers the currentPkg of a re-resolving edge
Custom resolvers received currentPkg: null on every call because nothing
populated ResolveOptions.current_pkg. Mirror pnpm's hand-off
(currentPkg: extendedWantedDep.infoFromLockfile in resolveDependencies):
- the tree walker now derives each edge's prior lockfile snapshot key
(importer refs satisfies-gated like pnpm's referenceSatisfiesWantedSpec;
transitive keys from the parent's snapshot) and, when the edge
re-resolves anyway, threads the recorded entry into the per-call
ResolveOptions as currentPkg
- a Registry ({integrity}-only) entry regains its derived tarball URL,
like pnpm's pkgSnapshotToResolution; pick_registry_for_package moved
from pacquet-resolving-npm-resolver to pacquet-lockfile (next to
npm_tarball_url) so the deps-resolver can route scoped packages, with
a re-export keeping existing callers unchanged
- children of a freshly resolved parent that landed back on its
recorded version keep their prior refs for currentPkg purposes
(pnpm's non-updated parentPkg arm) via the new ReuseSource::PriorOnly;
subtree reuse semantics are unchanged
- the per-wanted memo key includes the prior key so two edges sharing a
specifier but recording different versions never share a
currentPkg-dependent result
The e2e test ports upstream's 'custom resolver receives currentPkg on
subsequent installs': a forced re-resolve receives the prior entry with
the re-derived tarball URL, and echoing it back keeps the pinned
version.
* fix(pacquet): gate prior child refs on the canonical dep-path id
The 'parent landed back on its recorded entry' check compared the
recorded snapshot key against the raw resolver id, which is not the
canonical dep-path form: build_pkg_id_with_patch_hash may append a
(patch_hash=...) suffix and name@-prefix file:/git/tarball ids. Extract
the comparison into landed_on_prior_entry, which strips suffixes from
both sides (the recorded key's peers/patch hash via without_peer, the
resolved id via remove_suffix) so the gate keys on which package
version the parent is, like pnpm's parentPkg.updated flag.
Port pnpm's custom resolver hooks to the Rust pacquet engine: a pnpmfile can export a top-level `resolvers` array whose entries override built-in dependency resolution and force re-resolution when needed. See pnpm/pnpm#10389 for the TypeScript-side feature request that motivated this port.
## What's included
- **Hook contract** — `CustomResolver` trait (`canResolve` / `resolve` / `shouldRefreshResolution`) mirroring `hooks/types/src/index.ts`. All three methods are optional upstream, so the Node worker reports per-resolver capability flags in one IPC round trip and pacquet skips calls a resolver doesn't implement (mirrors pnpm's `if (!customResolver.canResolve || !customResolver.resolve) continue` and `checkCustomResolverForceResolve`'s hook filter).
- **Node IPC** — the long-lived pnpmfile worker gained `resolvers` (capabilities) and `resolver` (method invocation) requests. Methods are invoked with `this` bound to the resolver object, like pnpm. Pending-request cleanup is cancellation-safe via an RAII guard.
- **Adapter & chain integration** — `CustomResolverAdapter` bridges the JSON hook contract to the typed `Resolver` trait. Custom resolvers are built into the inner resolver chain ahead of the built-ins (upstream chain priority), inside the prefetching/observing wrappers so their tarball results get resolve-time prefetch and pnpr streaming. `canResolve` results are memoized keyed `alias@bareSpecifier`, exactly like pnpm's `getCustomResolverCacheKey`. A resolver-returned `manifest` passes through (pnpm spreads the whole hook result). Payloads match upstream: `prevSpecifier`, and resolve opts carry `lockfileDir` / `projectDir` / `preferredVersions` / `currentPkg`.
- **`shouldRefreshResolution` semantics** — port of `checkCustomResolverForceResolve`: the hook receives the merged packages+snapshots entry (pnpm's in-memory `PackageSnapshot`), checks run concurrently with first-true/first-error short-circuit, and a throwing hook aborts the install (`PNPMFILE_FAIL`). A `true` verdict defeats both up-to-date optimizations, as documented in the hook's contract:
- the prefer-frozen dispatch consults the hook (pnpm: `forceResolutionFromHook` → `needsFullResolution` blocks `isFrozenInstallPossible`) and routes to the fresh-resolve path with lockfile reuse disabled (`UpdateReuseScope::None`);
- the optimistic repeat-install fast path now ports the pnpmfile branch of `patchesOrHooksAreModified`: the workspace state records the loaded pnpmfile list, and an added/removed/edited pnpmfile invalidates the mtime check.
- **`CurrentPkg`** — added to `ResolveOptions`, matching upstream's `currentPkg` shape `{id, name?, version?, resolution, publishedAt?}` (camelCase).
## Tests
- Adapter unit tests: missing `id`/`resolution`, invalid shapes, `canResolve` memoization, payload shapes, manifest passthrough.
- `check_custom_resolver_force_resolve` unit tests: port of upstream's `checkCustomResolverForceResolve.ts` suite (capability filter, true/false/error propagation, merged snapshot payload).
- Node IPC integration tests against a real pnpmfile: capabilities, `this` binding, round trips, error propagation, cancellation cleanup.
- CLI e2e tests against the mock registry: custom resolver precedence over the npm resolver, `shouldRefreshResolution` re-resolving past an up-to-date lockfile, and a throwing hook failing the install.
* fix(lockfile): record array-form engines as index-keyed entries
A package.json with `"engines": ["node >= 0.2.0"]` (e.g.
jsonparse@1.3.1) records `engines: {'0': node >= 0.2.0}` in pnpm's
lockfile — the shape Object.entries() yields for an array. pacquet
read engines via as_object only and dropped the field.
Part of https://github.com/pnpm/pnpm/issues/12266.
* fix(deps-resolver): keep a shared subtree's peer context stable across importers
A package first resolved under one importer keeps that walk's
missing-peer report in pnpm (missingPeersOfChildrenByPkgId,
https://github.com/pnpm/pnpm/blob/a751c7f27d/installing/deps-resolver/src/resolveDependencies.ts#L193):
an importer revisiting the package neither recomputes its subtree's
missing peers nor re-hoists optional peers the first context already
satisfied — its occurrence then shares the first context's
peer-suffixed variant. pacquet re-walked every importer's tree from
scratch, so an importer without e.g. @types/node hoisted the highest
satisfying version (25.x) for a subtree the root importer had already
resolved against its own @types/node (22.x), forking the snapshot into
duplicate suffix variants (verified against pnpm 11.6.0 with a
three-importer repro on @pnpm/meta-updater).
The walk now records, per package, the importer whose tree first
resolved it and the missing-peer names of that first peer walk. The
per-importer hoist input drops a missing peer declared under a
foreign-claimed ancestor when the claiming walk did not report it
missing — misses the first context could not satisfy either (e.g.
clipanion's typanion under a root that only hoists it later) stay
visible to every importer, which then hoists its own copy exactly like
pnpm. The final workspace-wide pass stays unscoped so peer warnings
still cover every importer.
Whole-monorepo lockfile diff vs fresh pnpm 11.6.0: 445 → 128 changed
lines; the remaining divergence is two importer entries (jest's
@types/node under __utils__/eslint-config and @cyclonedx's
spdx-expression-parse) plus per-level preferred-version picks (varint,
es-abstract), tracked in the issue.
Part of https://github.com/pnpm/pnpm/issues/12266.
* perf(deps-resolver): snapshot the hoist-missing scope once per importer
The hoist loop cloned the claim and first-walk-missing maps on every
iteration; suppression only consults entries recorded by earlier
importers (an importer's own loop additions are self-claimed and
exempt), so one Arc-shared snapshot per importer is equivalent.
Addresses a review comment on the PR.
## Summary
Four lockfile-parity fixes for pacquet, continuing https://github.com/pnpm/pnpm/issues/12266. Measured on the monorepo itself (fresh state, `install --lockfile-only`, back-to-back against the live registry vs **pnpm 11.6.0**), the real-lockfile document diff drops from **806 to 461 changed lines**. Two consecutive pacquet runs are byte-identical before and after.
### 1. Importer's regular dep wins over its own peer range (`fix(deps-resolver)`)
An importer that declares the same alias in both `peerDependencies` and a regular group (e.g. `@pnpm/logger`: `workspace:*` devDependency + `catalog:` peer — 67 importers in this repo) resolved both specs, and the peer's registry resolution overwrote the workspace link in the importers section (`version: 1001.0.1` instead of `link:../../core/logger`, 182 diff lines). `importer_direct_wanted_specs` now merges the groups the way pnpm spreads them in [`getWantedDependencies`](https://github.com/pnpm/pnpm/blob/01b3d45ddb/installing/deps-resolver/src/getWantedDependencies.ts#L32-L43): peers first, then `dependencies` < `devDependencies` < `optionalDependencies`, one wanted spec per alias. The `@pnpm/logger` resolution tallies now match pnpm's exactly.
### 2. A package's peer survives when it also lists the name as a dependency (`fix(deps-resolver)`)
Under `autoInstallPeers`, pnpm removes peer-shadowed names from a resolved package's `dependencies` **before** extracting peers ([resolveDependencies.ts#L1527-L1542](https://github.com/pnpm/pnpm/blob/01b3d45ddb/installing/deps-resolver/src/resolveDependencies.ts#L1527-L1542)), so the peer edge supplies the package and the depPath carries the suffix. pacquet did the inverse (dropped the peer, walked the own dep), so `@babel/parser` — whose packageExtensions-added `@babel/types` peer is also its regular dependency — lost its `(@babel/types@7.29.7)` suffix and its `peerDependencies:` block. Also aligns `extract_peer_dependencies` with [`peerDependenciesWithoutOwn`](https://github.com/pnpm/pnpm/blob/01b3d45ddb/installing/deps-resolver/src/resolveDependencies.ts#L1840-L1864): the package's own name counts as an own dep, and a `peerDependenciesMeta`-only entry becomes a peer only when `optional: true`. The non-`autoInstallPeers` arm (omit only peers resolvable from the parent scope) is not ported — pacquet's per-package children cache has no parent context; behavior there matches pnpm whenever the peer is not in scope.
### 3. Overrides apply to `peerDependencies` (`fix(package-manager)`)
Ports the peer arm of [`overrideDepsOfPkg`](https://github.com/pnpm/pnpm/blob/01b3d45ddb/hooks/read-package-hook/src/createVersionsOverrider.ts#L68-L129): a matched peer is deleted on `-`, rewritten in place when the override value is a valid peer range, otherwise written into `dependencies` while the declared peer stays. Fixes e.g. `ajv@>=7.0.0-alpha.0 <8.18.0 → >=8.18.0` not rewriting peer ranges and `@yarnpkg/libzip` losing its `(@yarnpkg/fslib@3.x)` suffix.
### 4. Optional-peer hoist: run-extended preferred versions, meta-only peers excluded (`fix(deps-resolver)`)
Corrects the model from pnpm/pnpm#12267. pnpm folds **every run-resolved version** into `ctx.allPreferredVersions` ([resolveDependencies.ts#L1483-L1488](https://github.com/pnpm/pnpm/blob/01b3d45ddb/installing/deps-resolver/src/resolveDependencies.ts#L1483-L1488), since pnpm/pnpm#7812) and `getHoistableOptionalPeers` reads that map after each wave — so an optional peer with a **real `peerDependencies` entry** (eslint's `jiti`) resolves against a provider anywhere in the freshly resolved tree. What keeps `debug`'s `supports-color` bare is not a static map but the missing-peer set: [`getMissingPeers`](https://github.com/pnpm/pnpm/blob/01b3d45ddb/installing/deps-resolver/src/resolveDependencies.ts#L1773-L1782) iterates `peerDependencies` only, so a **meta-only** peer never feeds the hoist. Both behaviors verified empirically against pnpm 11.6.0 (`eslint` + `cosmiconfig-typescript-loader` hoists `jiti@2.6.1`; `debug` + `concurrently` stays bare). pacquet's static-snapshot approach got the meta-only case right by the wrong mechanism and under-hoisted every real-entry optional peer — the missing `(jiti@2.6.1)`, `(typanion@3.14.0)`, `(conventional-commits-parser@6.4.0)`, `(@types/node@…)` suffixes and their cascades on the monorepo. The 12267-era regression test asserted the anti-pnpm behavior for the real-entry shape and was inverted; a new test pins the meta-only shape.
## Verification
- New unit tests in `pacquet-resolving-deps-resolver` (importer group merge ×4, peer-vs-own-dep shadowing ×3, optional-peer hoist real-entry/meta-only ×2) and `pacquet-package-manager` (peer overrides ×4).
- Full workspace suite: 3218 tests pass; clippy `--deny warnings` clean; rustfmt + typos clean.
- Whole-monorepo lockfile diff vs fresh pnpm 11.6.0: 806 → 461 changed lines; `@pnpm/logger`, `jiti`, `typanion`, `@babel/types`, `ajv` categories eliminated. Two consecutive pacquet runs byte-identical.
* fix: resolve musl pacquet binary on musl-based systems
The pacquet binary packages are split by libc on linux and only the
matching one is installed, but resolvePacquetBin always asked for the
glibc name. On Alpine and other musl systems the frozen install failed
with: Cannot find module '@pacquet/linux-x64/pacquet'.
fixpnpm/pnpm#12049
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style: sort imports in runPacquet.ts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: verify the musl pacquet binary package on musl-based systems
The signature verification hard-coded the glibc platform package name,
so on musl systems it verified a package other than the binary that is
actually spawned. Share one platform-package-name helper between
resolvePacquetBin and collectPacquetPackagesToVerify.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix: contain hoisted dependency aliases (GHSA-fr4h-3cph-29xv)
The `nodeLinker: hoisted` install restores its dependency graph straight
from the lockfile via `lockfileToHoistedDepGraph`, which joins each
dependency alias under a `node_modules` directory and imports the
package files there. On a frozen / up-to-date lockfile, resolution is
skipped entirely, so the alias validation added for the resolution path
never runs. A crafted lockfile alias such as `../../../escape` could
therefore escape the install root, and reserved aliases such as `.bin`,
`.pnpm`, or `node_modules` could overwrite pnpm-owned layout.
Validate every alias at the hoisted-graph directory sink. The shared
`safeJoinModulesDir` helper now rejects aliases that are not valid npm
package names (path-traversal, absolute, and reserved names) in addition
to its containment check, and the hoisted graph routes its `dep.name`
sink through it. Pacquet mirrors the boundary: `safe_join_modules_dir`
validates the hoister's `dep.0.name` before adding the graph node or
recursing, reusing the same dependency-name rule it already applies to
direct-dependency aliases. Both stacks surface
`ERR_PNPM_INVALID_DEPENDENCY_NAME`.
---
Written by an agent (Claude Code, claude-fable-5).
* fix: reject invalid dependency aliases at the lockfile verification gate
Add an always-on, policy-independent structural check to
verifyLockfileResolutions that rejects any importer or package-snapshot
dependency alias that is not a valid npm package name. A dependency
alias becomes a `node_modules/<alias>` directory at link time, so an
alias with path-traversal segments or a reserved name (`.bin`, `.pnpm`,
`node_modules`) could escape the install root or overwrite pnpm-owned
layout.
This complements the linker-sink guards: the verifier runs before any
fetch or filesystem work and covers every node linker at once, while the
sink guards still protect the `trustLockfile` path the verifier skips.
The check runs before the cache lookup so a record written by a version
that predates the rule cannot fast-path around it, and before the
`packages` guard so a tampered importer alias is caught even when nothing
is installed.
`isValidDependencyAlias` is now exported from `@pnpm/installing.deps-resolver`
and reused here. Pacquet mirrors the gate in its lockfile-verification
crate with a matching `ERR_PNPM_INVALID_DEPENDENCY_NAME` verdict.
---
Written by an agent (Claude Code, claude-fable-5).
* docs(package-manager): drop redundant explicit intra-doc link target
`is_valid_dependency_alias` is in scope via `use`, so the bare
intra-doc link resolves on its own. The explicit path target tripped
`rustdoc::redundant-explicit-links` under the CI Doc job's
`cargo doc --document-private-items` (the local pre-push hook runs
`cargo doc` without that flag, so it didn't surface).
---
Written by an agent (Claude Code, claude-fable-5).
* refactor(lockfile-verification): fold the alias check into the single candidate pass
The dependency-alias check ran as its own full traversal of the lockfile
in addition to collectCandidates' existing pass over every package
snapshot. Fold it into that pass instead: collectCandidates now also
validates each importer and snapshot dependency alias and returns the
invalid ones alongside the resolution-shape violations, so the lockfile
is walked once per verification rather than twice.
Because collectCandidates runs after the verification-cache lookup, the
alias check is now covered by the cache the same way the resolution-shape
check is: a new dependencyAliasCheck cache identity makes a record
written before this rule existed fail canTrustPastCheck, forcing a
re-verification. The shared helper is renamed
withOfflineCheckCacheIdentities and appends both offline-structural-check
identities.
No behavior change for valid lockfiles; the same
ERR_PNPM_INVALID_DEPENDENCY_NAME is thrown for invalid aliases. Mirrored
in pacquet's lockfile-verification crate.
---
Written by an agent (Claude Code, claude-fable-5).
* refactor: declare pushInvalidAliases after its caller, trim duplicated comments
Move `pushInvalidAliases` below `collectCandidates`, following the
repo's declare-after-use convention. Collapse the repeated "an alias
becomes a node_modules directory, so a traversal/reserved name escapes
or overwrites layout" explanation that was copied across the verifier,
the hoisted-graph error, and the pacquet mirror down to a single
reference each — the full rationale lives once in the validating sink
(`safeJoinModulesDir` / `safe_join_modules_dir`) and the user-facing
error hints.
---
Written by an agent (Claude Code, claude-fable-5).
## Problem
`pnpm install` with a frozen lockfile got noticeably slower because lockfile verification blocks every later install stage. The verification gate (the `minimumReleaseAge`/`trustPolicy` policy revalidation plus the tarball-URL anti-tamper check) issues a registry round trip per lockfile entry, and the whole install waited for it to finish before any fetching or linking could begin.
## Change (pnpm / TypeScript)
Run lockfile verification **concurrently** with fetching and linking instead of blocking on it, while keeping two guarantees intact:
1. **No lifecycle script runs on an unverified lockfile.** A `verifyLockfile` gate is threaded into both `buildModules` call sites — `headlessInstall` (frozen path) and `_installInContext` (full-resolution path) — and awaited immediately before any dependency lifecycle script runs. The projects' own `preinstall`/`postinstall` hooks are held to the same gate at both `runLifecycleHooksConcurrently` call sites, covering the `enableModulesDir: false` path that skips the build phase. If verification failed, the gate throws before a single script executes.
2. **The verdict is always reconciled.** `settleInstall(_install(), verifyLockfilePromise)` awaits the verification verdict first so it takes precedence and fails fast (even mid-install), then surfaces the install's result/error. This also covers paths that skip the build phase entirely (`ignoreScripts`, `lockfileOnly`, empty lockfile).
Verification's synchronous prologue (cache lookup, lockfile hash, candidate collection) still runs against the pristine lockfile before `_install()` mutates `ctx.wantedLockfile`, so the concurrent async fan-out reads a stable snapshot — no data race.
The verification verdict deliberately takes precedence over a concurrent install error: `pnpm add`'s full-resolution path can throw its own generic "resolution-policy violations produced but no handler wired" for the same underlying violation, and `settleInstall` makes sure the specific `minimumReleaseAge`/`trustPolicy` error is what surfaces.
## Change (pacquet / Rust)
Same optimization ported to `pacquet/crates/package-manager/`. `Install::run` builds the resolution verifiers up front but dispatches the verification fan-out per path:
- **Frozen materialization path:** verification runs concurrently with `CreateVirtualStore` (the fetch), settled with a `select!` so the verdict takes precedence: a rejected lockfile aborts the fetch in flight (fail-fast), while a fetch failure waits for the verdict and only surfaces once the lockfile is known trusted — an unrelated fetch error can't mask a rejected lockfile. The verdict is always reached before linking and `BuildModules`, so no dependency lifecycle script runs on an unverified lockfile.
- **Lockfile-only / up-to-date short-circuits and the fresh-resolve path:** keep an eager blocking gate — they have no fetch to overlap.
A verification failure surfaces as the same `InstallError::LockfileVerification` variant regardless of which path produced it.
On the pnpr client, a frozen restore now skips resolution entirely: tarball downloads start from the local lockfile at t=0 (filtered through one batched store-index existence probe, so a warm store prefetches nothing) while the server delivers only the trust verdict via the new `POST /v1/verify-lockfile` endpoint, concurrently with the fetch.
## Tests
- pnpm: `test/install/trustLockfile.ts` covers the rejection itself, the `trustLockfile` opt-out, and both script gates — a dependency `postinstall` never runs when verification fails, and the projects' own lifecycle hooks never run either, asserted on the `enableModulesDir: false` path with a *slow*-rejecting verifier (an instantly-rejecting one aborts the install before the hooks are attempted, which would hide a missing gate). Existing verification/lifecycle/`minimumReleaseAge` suites pass.
- pacquet: existing `frozen_lockfile_gate_rejects_under_huge_minimum_release_age` (unit) and `install_fails_under_huge_minimum_release_age` (CLI) assert the frozen install aborts with no virtual-store materialization on verification failure — proving the fail-fast settle cancels the fetch. New: `without_store_hits` + `StoreIndex::contains_many` unit tests pin the warm-store prefetch filter, and the frozen pnpr CLI test swaps the registry for a zero-expectation server before the restore, proving a warm-store frozen restore makes no registry requests.
- pnpr client/server: integration tests cover `/v1/verify-lockfile` accepting a clean lockfile, rejecting a policy violation, honoring `trustLockfile`, and forwarding the client's credential map (each verify call targets a fresh pnpr so no verdict/metadata cache can satisfy it without exercising the credential).
- clippy / `cargo doc -D warnings` / rustfmt / eslint clean; package-manager, lockfile-verification, store-dir, pnpr-client, and CLI pnpr-install suites all pass.
## Behavioral nuance
On a *rejected* lockfile, fetching/linking may now have partially populated the store/`node_modules` before the abort (previously nothing ran, since verification went first). The command still fails with the same error code and no lifecycle scripts run.