Commit Graph

61 Commits

Author SHA1 Message Date
Zoltan Kochan
0a4d6656c9 feat(pacquet): implement the update command (#12102)
* feat(package-manager): implement the `update` command in pacquet

Port pnpm's `update` (aliases `up`/`upgrade`) onto pacquet's
always-fresh-resolve install path.

- Compatible bump: withhold matched names' lockfile pins from the
  preferred-versions seed (new `UpdateSeedPolicy`) so they re-resolve
  to highest-in-range; `package.json` is left untouched. This is what
  distinguishes `update` from `install`.
- `--latest`: fetch each matched direct dep's `latest` tag and rewrite
  the manifest range (`^v`, or exact under `--save-exact`), like `add`.
- Selectors: bare-name/glob patterns (`depth>0`, no `--latest`) match
  every locked package name at any depth; versioned (`foo@2`) or
  `--latest` selectors match direct deps only; `--latest` + spec is
  rejected with `ERR_PNPM_LATEST_WITH_SPEC`.
- CLI flags: `-L/--latest`, `-E/--save-exact`, `-P/-D/--no-optional`
  (faithful `makeIncludeDependenciesFromCLI`), `--depth`,
  `--lockfile-only`, `-i/--interactive` (dialoguer + inline outdated
  check).
- `--global` and `--workspace` error out for now: the global-dir and
  workspace-version-linking subsystems are not ported yet.

The resolver's `UpdateBehavior` tri-state and the npm resolver's
`include_latest_tag` already existed; this change drives them from a
CLI command.

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

* feat(package-manager): port more pnpm update tests + fix selector parsing

Reviewing pnpm's `update` test suite surfaced gaps:

- Fix `parse_update_param` to match pnpm's `parseUpdateParam`: search
  for the version `@` starting at index 2 for `!`-negated patterns (1
  otherwise), so `!@scope/pkg-*` is no longer wrongly split into
  pattern `!` + version `scope/pkg-*`. Negation selectors now work.
- Add `--no-save`: the range rewrites still drive resolution (lockfile
  updates) but `package.json` is not persisted. Mirrors pnpm's
  `updatePackageManifest: opts.save !== false`.
- Add `ERR_PNPM_NO_PACKAGE_IN_DEPENDENCIES`: a selector that matches no
  direct dependency under `--depth 0` (without `--latest`) now errors,
  matching pnpm; `--latest` with an unmatched selector is a no-op.

Ports the negation-pattern, `--no-save`, and no-package-in-deps tests
from pnpm's `installing/commands/test/update/update.ts`.

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

* perf(package-manager): avoid per-snapshot String alloc in update seed filter

`UpdateSeedPolicy::DropOnly`'s snapshot filter allocated a `String` for
every lockfile snapshot key. Parse the (small) update-target set to
`PkgName` once and compare against `key.name` directly. Addresses a
review comment on #12102.

Written by an agent (Claude Code, claude-opus-4-8).
2026-06-01 15:01:22 +02:00
Khải
577a90f819 feat(pacquet): --resume-from, --report-summary (#12093)
* feat(cli): port recursive run with --resume-from and --report-summary

Port pnpm's `pnpm run -r` (recursive run) to pacquet, including the
`--resume-from` and `--report-summary` flags, which previously existed
only in the TypeScript CLI.

- `pacquet -r run <script>` now runs the script in every workspace
  project in topological order, mirroring pnpm's runRecursive: discover
  projects, build the inter-project dependency graph, sort it into
  chunks via graph_sequencer (the port of sortProjects), and execute.
- `--resume-from <pkg>` drops every chunk before the one containing
  <pkg>, mirroring getResumedPackageChunks; an unknown package fails
  with ERR_PNPM_RESUME_FROM_NOT_FOUND.
- `--report-summary` writes pnpm-exec-summary.json with the per-package
  status (queued/running/passed/skipped/failure) and duration, nested
  under an executionStatus key, mirroring writeRecursiveSummary.
- `--no-bail` keeps running after a failure (recursive runs bail by
  default). Failures surface ERR_PNPM_RECURSIVE_FAIL, or
  ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL when bailing; a run that matches no
  script fails with ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT.

A new executor helper, execute_shell_with_status, returns the child's
exit status so per-package pass/fail can be recorded; execute_shell is
unchanged.

Not yet ported (noted in the module): --no-sort, --reverse,
--workspace-concurrency parallelism, --filter narrowing of the selected
set, and the RegExp script selector. The selected set is every workspace
project, matching pacquet's currently-unfiltered install.

Integration tests port the upstream resume-from and report-summary
cases from exec/commands/test/runRecursive.ts.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

* test(cli): gate recursive-run tests to unix and add macro trailing commas

Fix two CI failures on the recursive-run integration tests:

- Dylint (`perfectionist::macro_trailing_comma`): add the trailing comma
  to the four multi-line `assert!` invocations.
- Lint and Test (windows-latest): the shared helpers are used only by
  the Unix-gated tests, so on Windows they tripped `dead_code` under
  `-D warnings`. Gate the whole file with `#![cfg(unix)]` (the build
  scripts run through pacquet's `sh -c` executor anyway), matching the
  single-package `run` tests.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

* test(cli): cover recursive-run bail summary and no-script branches

Fill the two coverage holes in the recursive-run handler:

- bail + report-summary: the first failing script writes the summary,
  then aborts with ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL; a package that
  sorts after the failure stays `queued`.
- no-script: a recursive run for a script no package defines fails with
  ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT, and `--if-present` turns that into a
  clean no-op.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

* test(cli): cover the bail path without --report-summary

The bail tests always passed --report-summary, leaving the
report-summary-off side of the bail block (recursive.rs:136) uncovered.
Add a test for a failing script with bail on and no --report-summary:
it still fails with ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL and writes no
summary file. Verified with cargo llvm-cov that recursive.rs now has no
missing lines.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

* fix(cli): run each recursive-run script from its own package root

Recursive run spawned every package's script via `sh -c` without
setting the working directory, so scripts ran in pacquet's process CWD
(the workspace root) instead of their own package root. That breaks
scripts relying on relative paths and diverges from pnpm, whose
`runLifecycleHook` runs with `pkgRoot` as the working directory.

- Give `execute_shell_with_status` a `current_dir` argument (factored
  through a private `spawn_shell` helper); `execute_shell` keeps its
  inherited-CWD behavior, so its callers are unchanged.
- Pass each package's root as the script's working directory.
- Make the marker-based recursive-run tests cwd-sensitive: scripts now
  write a relative `ran.txt`, and the tests assert it lands under each
  package root (and not at the workspace root), so a wrong-CWD
  regression fails the suite.

https://claude.ai/code/session_01QUdrDcP9iU3DwxR2TATobQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-01 12:48:10 +02:00
Zoltan Kochan
54d2b57000 feat(pnpr): server-accelerated installs via pnprServer (endpoints + client + CLI) (#12077)
## What

Adds an opt-in **`pnprServer`** setting that offloads the slow part of an install — dependency resolution and computing which files the local store is missing — to a [pnpr](https://github.com/pnpm/pnpm/tree/main/pnpr) server, which streams back only the missing files. `node_modules` is still linked **locally** from the server-produced lockfile (like server-side rendering: the compute runs remotely, the result is materialized locally).

Realizes the agent concept from [RFC #9](https://github.com/pnpm/rfcs/pull/9), reworked around how it's actually used and rewritten in Rust on pacquet + pnpr.

## How it works

1. `pacquet install` (with `pnprServer` set) handshakes the server — `GET /-/pnpr` — to negotiate a protocol version.
2. It `POST`s `/v1/install` with the project's dependencies, the integrities already in its store, and **its own registry config** (default `registry`, `namedRegistries`, `overrides`, `minimumReleaseAge`).
3. The server resolves against *those* registries, fetches any uncached packages into its store, and streams NDJSON: `D` (missing file digests), `I` (pre-packed store-index entries), `L` (lockfile + stats).
4. The client downloads the missing files from `/v1/files` (gzip binary), writes them into its CAFS **by digest** (no re-hashing), writes the index entries, and runs a frozen install to link `node_modules` from the server's lockfile.

## Pieces

- **Server (`pnpr`)** — `GET /-/pnpr` handshake + `POST /v1/install` (NDJSON) + `POST /v1/files` (gzip), additive and opt-in alongside the npm-compatible API. Resolves against the client-sent registries, interning a `&'static Config` per distinct client config to bound the leak.
- **Client (`pacquet-pnpr-client`)** — `PnprClient`: reads store integrities, negotiates the protocol version, sends the registry config, parses the stream, materializes files + index entries, returns the lockfile. Rejects unrequested file entries and repairs truncated CAFS files.
- **CLI** — the `pnprServer` setting (`--pnpr-server`, `pnprServer:` in `pnpm-workspace.yaml`, `PNPM_CONFIG_PNPR_SERVER`). When set, `pacquet install` routes through the client and then links locally — pnpm's `install()` → `installFromPnpmRegistry` shape. `trustPolicy: no-downgrade` is refused (the server can't enforce it), matching pnpm's `TRUST_POLICY_INCOMPATIBLE_WITH_AGENT`.

## Design notes

- **A distinct URL, not the registry.** The server resolves from the registries the client sends, so it's a compute service — not "a registry that resolves from itself" — which is why it's a separate `pnprServer` URL rather than reusing `registry`. The same server works for any client's registry setup, and a single pnpr can be both registry and `pnprServer`.
- **Handshake = version negotiation + fail-fast.** Explicit opt-in, so there's no silent fallback to local resolution; a non-pnpr server (404) or a version mismatch errors clearly.
- **Naming:** everything is `pnpr`; "agent" survives only in upstream citations (`@pnpm/agent.client`, the pnpm-agent PoC, pnpm's `TRUST_POLICY_INCOMPATIBLE_WITH_AGENT` error code).

## Tests

- `pacquet-pnpr-client`: resolve + download, multi-file package, warm-store no-op, and handshake rejection. The pnpr server's own uplink is left at the default, so resolution provably uses the **client-sent** registry.
- `pacquet-cli`: a real `pacquet install --pnpr-server <url>` against an in-process pnpr (resolving from the mocked fixtures registry) links `node_modules`.
- `pnpr`: `/v1/files` binary-framing round-trip + handshake route.

Full suites green; clippy / dylint (Perfectionist) / fmt / taplo / `cargo doc -D warnings` clean.

## Deferred

Auth/credential forwarding (so private/scoped registries resolve), `pacquet add` / `remove` via `pnprServer`, multi-project workspaces, and true streaming (responses are buffered today).

Refs https://github.com/pnpm/rfcs/pull/9
2026-05-31 16:50:20 +02:00
Zoltan Kochan
394ee27e09 feat(tarball): support remote https-tarball direct dependencies (#12076) 2026-05-30 11:42:53 +02:00
dependabot[bot]
e2f801e4ec chore(cargo): bump tar from 0.4.45 to 0.4.46 (#12074)
Bumps [tar](https://github.com/composefs/tar-rs) from 0.4.45 to 0.4.46.
- [Release notes](https://github.com/composefs/tar-rs/releases)
- [Commits](https://github.com/composefs/tar-rs/compare/0.4.45...0.4.46)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 0.4.46
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 23:41:58 +02:00
Zoltan Kochan
c5d9d3a8f3 refactor(pnpr): rename pnpm-registry to pnpr (#12069)
* refactor(pnpr): rename pnpm-registry to pnpr

Rename the registry server across the board to match the npm wrapper
package name, which was already `@pnpm/pnpr`.

- crate `pnpm-registry` -> `pnpr`, `pnpm-registry-fixtures` -> `pnpr-fixtures`
- binaries `pnpm-registry` -> `pnpr`, `pnpm-registry-prepare` -> `pnpr-prepare`
- module paths and log targets `pnpm_registry::*` -> `pnpr::*`
- binary-locating env vars `PNPM_REGISTRY_BIN` -> `PNPR_BIN`,
  `PNPM_REGISTRY_PREPARE_BIN` -> `PNPR_PREPARE_BIN`
- top-level directory `registry/` -> `pnpr/` (crates, npm wrapper, fixtures)

The registry-mock storage concept is intentionally left as-is:
`PNPM_REGISTRY_MOCK_PORT`/`PNPM_REGISTRY_MOCK_STORAGE`/`PNPM_REGISTRY_STORAGE`,
the `~/.cache/pnpm-registry/storage` path + benchmark cache keys, and the
external `pnpm-registry-mock` npm package referenced in test fixtures.

* style(pnpr): rustfmt import grouping after rename

* ci(pnpr): point typos at pnpr instead of removed registry dir

* chore(pnpr): update pre-push path filter from registry to pnpr
2026-05-29 20:02:10 +02:00
dependabot[bot]
36e6a27066 chore(cargo): bump sysinfo from 0.39.1 to 0.39.2 (#12059)
Bumps [sysinfo](https://github.com/GuillaumeGomez/sysinfo) from 0.39.1 to 0.39.2.
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.39.1...v0.39.2)

---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.39.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 17:58:43 +02:00
dependabot[bot]
3db9dcdaf3 chore(cargo): bump serde_json from 1.0.149 to 1.0.150 (#12063)
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.149 to 1.0.150.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.150)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.150
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 17:53:33 +02:00
dependabot[bot]
e56b126299 chore(cargo): bump getrandom from 0.3.4 to 0.4.2 (#12060)
Bumps [getrandom](https://github.com/rust-random/getrandom) from 0.3.4 to 0.4.2.
- [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/getrandom/compare/v0.3.4...v0.4.2)

---
updated-dependencies:
- dependency-name: getrandom
  dependency-version: 0.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 17:12:38 +02:00
dependabot[bot]
9daea1ecb0 chore(cargo): bump bcrypt from 0.17.1 to 0.19.1 (#12062)
Bumps [bcrypt](https://github.com/Keats/rust-bcrypt) from 0.17.1 to 0.19.1.
- [Commits](https://github.com/Keats/rust-bcrypt/compare/v0.17.1...v0.19.1)

---
updated-dependencies:
- dependency-name: bcrypt
  dependency-version: 0.19.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 17:12:08 +02:00
dependabot[bot]
b5b1cd6e62 chore(cargo): bump tower-http from 0.6.8 to 0.6.11 (#12064)
Bumps [tower-http](https://github.com/tower-rs/tower-http) from 0.6.8 to 0.6.11.
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.8...tower-http-0.6.11)

---
updated-dependencies:
- dependency-name: tower-http
  dependency-version: 0.6.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 16:33:39 +02:00
Khải
faf848434b feat(pacquet/cli): --filter, --filter-prod (initial) (#12000)
* feat(pacquet/cli): add `--filter` / `--filter-prod` and workspace project filtering

Port pnpm's `@pnpm/workspace.projects-graph` and
`@pnpm/workspace.projects-filter` as the new
`pacquet-workspace-projects-graph` and `pacquet-workspace-projects-filter`
crates, and expose the `--filter` / `--filter-prod` CLI flags (stored into
`Config::filter` / `Config::filter_prod`).

- `parse_project_selector` parses selector strings (name glob, `...`
  dependents/dependencies, `^` exclude-self, `!` exclude, `{dir}`, `[since]`).
- `create_projects_graph` computes inter-project edges via `workspace:`,
  semver version/range, and local-path resolution.
- `filter_workspace_projects` resolves selectors against the graph;
  `filter_projects` builds the graph(s) and applies a `WorkspaceFilter` list.

The `[since]` changed-packages selector parses but is rejected with
`FilterError::UnsupportedDiffSelector` (git-diff project selection is not
ported). As with `--recursive`, the install still materializes every
workspace importer in one shared pass, so narrowing the install to the
selected projects remains a follow-up; the `known_failures` hoist stubs for
`--filter` selected-projects installs are unchanged.

Ports the upstream `parseProjectSelector` and `filterWorkspaceProjects` tests;
the diff-based cases are stubbed as `known_failures`. Updates
plans/TEST_PORTING.md.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* docs(pacquet/workspace-projects-filter): disambiguate intra-doc link

`parse_project_selector` is both a module and a re-exported function at
the crate root, so the bare intra-doc link was ambiguous and failed
`cargo doc` under `-D warnings`. Link to the function with `()`.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* fix(pacquet/workspace-projects-filter): faithful selector parse + graph parity

Review-driven fixes for parity with upstream `@pnpm/workspace.projects-filter`
and `projects-graph`:

- `parse_project_selector`: the hand-rolled selector matcher now backtracks
  like the upstream regex `^([^.][^{}[\]]*)?(\{[^}]+\})?(\[[^\]]+\])?$`. The
  name group's first char may be a brace/bracket, so `!{foo` is an exclude
  selector (was previously parsed as include, inverting selection) and
  `{[master]` keeps its `[master]` diff. Added cases.
- `create_projects_graph`: a path-style `workspace:` token (`workspace:../foo`)
  now resolves by directory rather than failing version resolution and
  emitting a spurious `unmatched` entry, matching upstream's
  `workspacePrefToNpm` + `npa.resolve` path.
- `glob::segment_match`: replaced the greedy single-pass match with a
  backtracking wildcard match so multiple `*` in one path segment
  (`{packages/a*-*}`) match correctly.
- Deduplicated `lexical_normalize`: it now lives in
  `pacquet-workspace-projects-graph` and the filter crate reuses it.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* fix(pacquet/workspace-projects-graph): satisfy typos, perfectionist, rustdoc CI

- Rename closure params off single letters (`|p|` → `|project|`,
  `|&v|` → `|&version|`) to satisfy `perfectionist::single_letter_closure_param`.
- Use raw strings for the `.\` / `..\` path prefixes
  (`perfectionist::prefer_raw_string`) and add the missing `matches!`
  trailing comma (`perfectionist::macro_trailing_comma`).
- Disambiguate every `create_projects_graph` intra-doc link with `()`
  (module and re-exported function share the name), fixing
  `rustdoc::broken_intra_doc_links` under nightly.
- Rename the `unparseable_braces…` test to `unparsable_…` (typos).

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* fix(pacquet/workspace-projects-filter): clear remaining Dylint findings; add filter-prod coverage

- Reorder derives to `prefix_then_alphabetical` (Default before Clone)
  for `perfectionist::derive_ordering`.
- Replace U+2026 `…` with ASCII `...` in comments
  (`perfectionist::unicode_ellipsis_in_comments`).
- Add coverage the cloud review flagged as missing: the `--filter-prod`
  production-only graph (dev edges dropped), the prod-then-all union
  order, and `unmatched_filters` for a path selector that matches
  nothing.
- Document the clap global-flag limitation: `--filter` occurrences only
  merge within one side of the subcommand boundary.

Verified locally with `cargo dylint --all` and `cargo doc -D warnings`.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* test(pacquet/workspace-projects-filter): cover branches flagged by coverage

Add unit tests for every reachable line CodeCov flagged uncovered, and
delete the one unreachable branch:

- parser: empty input (`...`), empty brace group (`{}`), dot-prefixed
  name (`.foo`).
- filter: selector combining a directory and a name pattern, a selector
  with no name/dir/diff (`UnsupportedSelector`), and the `is_subdir`
  contract (incomparable / descendant / equal paths).
- glob: trailing `*` after an exact prefix.
- graph: `./`-segment collapse in a path spec, and the Windows
  drive-prefix predicate.
- Remove the unreachable `candidates.is_empty()` guard in
  `resolve_by_name_version` (`by_name` never holds empty vecs; `?`
  already covers absence).

All pure in-memory logic, so plain unit tests with constructed inputs
fit — no integration, filesystem fixtures, or DI seam needed. Verified
with `cargo dylint --all` and `cargo doc -D warnings`.

https://claude.ai/code/session_01BsrsKRRp5ntwqnTB7YREes

* refactor(pacquet/workspace-projects-graph): reuse pacquet-fs lexical_normalize

The graph crate carried its own copy of `lexical_normalize`, duplicating
`pacquet-fs`'s already fully-tested helper. For every input the graph
passes (absolute project roots and absolute-base joins) the two behave
identically — they differ only on unanchored leading `..`, which never
reaches this code. Drop the duplicate and re-export the `pacquet-fs` one,
removing the lone uncovered `Component::CurDir` branch in the process
(the shared helper already covers it).

https://claude.ai/code/session_0144D5iK3jHxrWENJFq6m8KF

* test(pacquet/workspace-projects-filter): cover already-walked node dedup

`pick_subgraph` skips a node it has already inserted via the
`walked.contains` guard, the branch that keeps the walk terminating on
diamonds and cycles. None of the existing fixtures reach a node by two
paths, so add a diamond graph (`top` -> {`left`, `right`} -> `shared`)
whose `...` dependency walk visits `shared` twice, exercising the guard.

https://claude.ai/code/session_0144D5iK3jHxrWENJFq6m8KF

* docs(pacquet/config): trim filter doc to its stable contract

Drop the second paragraph narrating the current install behavior
("still materializes every importer ... follow-up ... stored for
parity"). That described a transient state that goes stale the moment
pacquet consumes the filter during install. Keep only the stable
contract: what the field holds, that it mirrors pnpm's CLI-only
`filter` array, and that only the CLI layer populates it.

https://claude.ai/code/session_0144D5iK3jHxrWENJFq6m8KF

* fix(pacquet/workspace-projects-filter): concatenate absolute directory selectors like path.join

Upstream's `parseProjectSelector` resolves directory selectors with
`path.join(prefix, rel)`, which concatenates even when `rel` is absolute
(`path.join('/ws', '/pkg')` -> `/ws/pkg`). Rust's `Path::join` instead
drops `prefix` for an absolute `rel`, so `lexical_join` resolved a `{/pkg}`
selector to `/pkg` rather than `/ws/pkg`, diverging from pnpm.

Strip leading separators from `rel` before joining so an absolute
selector extends the prefix, and correct the doc comment, which described
the wrong (Rust) semantics. Add a parse test covering `{/pkg}`.

* fix(pacquet/workspace-projects-filter): normalize candidate path separators in dir globs

Address PR review comments:

- glob: normalize backslashes to `/` in the candidate path as well as the
  pattern, so a Windows `ProjectRootDir` rendered with backslashes by
  `PathBuf::to_string_lossy()` still matches a glob dir selector. Matches
  micromatch's effective separator handling.
- filter: include the offending selector in `UnsupportedSelector`,
  mirroring upstream's `Unsupported project selector: ${JSON.stringify(selector)}`.
- cli: add a regression test locking the documented clap mixed-placement
  limitation for the global `-F` flag.
- parse_project_selector: fix a doc typo ("directory directory-selectors").

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-29 16:18:45 +02:00
Zoltan Kochan
90fe4dc2d2 fix(package-manager): record configDependencies in workspace state (#12065)
* fix(package-manager): record configDependencies in workspace state

build_workspace_state hardcoded config_dependencies: None, so a pacquet
install wrote .pnpm-workspace-state-v1.json without the configDependencies
map. On the next pnpm run/node/exec, pnpm's checkDepsStatus compared the
live config's configDependencies against the missing recorded value, judged
node_modules out of sync, and reinstalled every time. When devEngines.runtime
is set with onFail: download, that reinstall also re-provisions the runtime.

Parse configDependencies from pnpm-workspace.yaml into Config (workspace-only,
cleared from global config.yaml like patchedDependencies) and write it through
build_workspace_state, mirroring pnpm's createWorkspaceState.

* refactor(package-manager): drop redundant comment in build_workspace_state

The fn doc comment and the WorkspaceSettings.config_dependencies field doc
already explain the createWorkspaceState parity and reinstall consequence.

* fix(config): model both configDependencies value shapes

The string-only Option<BTreeMap<String, String>> made an object-form
configDependencies entry ({ tarball?, integrity }) — valid in pnpm — fail
deserialization, turning a supported manifest into a hard config-load error.

Introduce ConfigDependency (untagged: VersionWithIntegrity string or
{ tarball?, integrity }) in the workspace-state crate and thread it through
Config and the workspace-state writer so both shapes round-trip verbatim,
matching pnpm. Also add the trailing comma perfectionist/dylint requires on
the new multi-line assert_eq! invocations.
2026-05-29 15:52:57 +02:00
Marvin Hagemeister
49e6074644 test: replace @pnpm/registry-mock with an in-repo in-process registry (#11927)
Replace the external `@pnpm/registry-mock` (Verdaccio) test dependency with an in-repo, in-process registry that serves package fixtures to **both** the pacquet Rust tests and the pnpm CLI (Jest) tests. No separately managed registry process is needed.

### How it works

- **Fixtures** live at `registry/.fixtures/packages/<name>/<version>/…`, moved verbatim from [`pnpm/registry-mock`](https://github.com/pnpm/registry-mock) (keyed by each `package.json`'s `name`+`version`).
- **`pnpm-registry-fixtures`** builds verdaccio-shaped storage from those fixtures; the in-tree **`pnpm-registry`** crate serves it.
  - Files whose names differ only by case (`@pnpm.e2e/with-same-file-in-different-cases`) and `bundleDependencies` trees are composed **in memory** by the builder, since neither can be committed to the working tree.
- **pacquet**: `pacquet-testing-utils`' `TestRegistry` starts the server lazily (once per process) in proxy mode, serving `@pnpm.e2e` fixtures locally and falling through to the npm uplink for real packages (`is-positive`, `is-negative`, …) — matching how registry-mock behaved.
- **pnpm CLI**: the `with-registry` Jest `globalSetup` builds storage from the fixtures via the new `pnpm-registry-prepare` binary (built from source in the Test CI job) and serves it with `pnpm-registry`. `REGISTRY_MOCK_PORT` / `REGISTRY_MOCK_CREDENTIALS` / `getIntegrity` now come from `@pnpm/testing.registry-mock`.

### Result

`@pnpm/registry-mock` is removed from every manifest, the catalog, and `packageExtensions`; `cargo test` / `cargo nextest run` / `just test` and the pnpm CLI Jest suites all run registry-backed tests without launching Verdaccio.
2026-05-29 14:35:45 +02:00
Zoltan Kochan
74dd8ba6e5 feat(config): make network settings configurable in pacquet (#12047)
* feat(config): make network settings configurable in pacquet

Port pnpm's `networkConcurrency`, `fetchTimeout`, `userAgent`, and
`npmrcAuthFile` to pacquet, replacing the hardcoded network-client
constants. Each is configurable via `pnpm-workspace.yaml`, the
`PNPM_CONFIG_*` env overlay, and a dedicated CLI flag, matching pnpm's
config surface.

- `pacquet-network`: add `NetworkSettings` (concurrency, timeout, UA)
  threaded into `ThrottledClient::for_installs`; `fetch_timeout` drives
  both the response and connect deadlines. `DEFAULT_FETCH_TIMEOUT_MS`
  (60s) and `DEFAULT_USER_AGENT` are the default sources.
- `pacquet-config`: add the four `Config` fields with cascade wiring;
  resolve `npmrcAuthFile` (and the `--userconfig` alias / env vars) to
  redirect the user-level `.npmrc` read in `Config::current`.
- Relocate the Rust->Node `host_platform`/`host_arch` mappers into
  `pacquet-detect-libc` (re-exported from `graph-hasher`) and reuse them
  to build pnpm's `pnpm/<version> npm/? node/? <platform> <arch>` UA.

Two intentional behaviour changes for parity: the default request
timeout moves from 300s to pnpm's 60s, and the default User-Agent moves
from the literal `pnpm` to pnpm's full UA format.

Refs https://github.com/pnpm/pnpm/issues/12042

* fix(config): correct npmrcAuthFile env resolution; port pnpm precedence tests

The user-level `.npmrc` path resolution applied the empty-string filter
once after the whole `or_else` chain, so an exported-but-empty
`PNPM_CONFIG_NPMRC_AUTH_FILE` short-circuited resolution instead of
falling through. Mirror pnpm's `readEnvVar` / `readNpmEnvVar` exactly:
filter `value !== ''` per variable, accept both `pnpm_config_*` /
`PNPM_CONFIG_*` cases, and add the `npm_config_userconfig` /
`NPM_CONFIG_USERCONFIG` compatibility fallback.

Port the translatable pnpm precedence tests from
`config/reader/test/index.ts` (resolution from the PNPM_CONFIG_* family,
lowercase variant, empty-falls-through, npmrc_auth_file outranks
userconfig, npm_config_userconfig compat fallback + pnpm-wins). pnpm's
credential-scoping tests that read the workspace .npmrc and the
userconfig simultaneously and re-scope per file don't translate yet:
pacquet uses a single-file (project-or-user) model, not pnpm's layered
merge. Global config.yaml sourcing of npmrcAuthFile remains deferred.

Refs https://github.com/pnpm/pnpm/issues/12042

* feat(config): merge multiple .npmrc sources with per-file credential scoping

Port pnpm's multi-file `.npmrc` layering and the credential-isolation
security boundary it provides. `Config::current` now reads the
user-level file (npmrcAuthFile / userconfig / ~/.npmrc), the global
`auth.ini`, and the project `.npmrc` together and merges them
(`user < auth.ini < workspace`) instead of reading just the first one
found.

Each source is rescoped before the merge (`NpmrcAuth::rescope_unscoped`,
ported from pnpm's `rescopeUnscopedCreds`): a file's *unscoped*
`_authToken` / `_auth` / `username` / `_password` / `cert` / `key` are
pinned to that file's own `registry=` (or the npmjs default when it
declares none), nerf-darted into a per-URI key. Once pinned, a
credential can never be pulled to a different registry that a
higher-priority file — or a later `pnpm-workspace.yaml` registry
override — sets. A deprecation warning is queued for each rescoped key.

`npmrcAuthFile` is now also sourced from the global `config.yaml`
(between `PNPM_CONFIG_USERCONFIG` and `npm_config_userconfig`),
completing pnpm's resolution order.

Behaviour changes for parity, both covered by ported tests: an unscoped
top-level `cert`/`key` becomes per-registry client identity (pinned to
its file's registry) rather than a global identity sent to every host;
and a user-file credential no longer leaks to a workspace registry
override. Ports the credential-scoping suite from
`config/reader/test/index.ts`.

Refs https://github.com/pnpm/pnpm/issues/12042

* fix(network): reject networkConcurrency=0; resolve user .npmrc base dir safely

Address review feedback on #12042:

- `ThrottledClient::for_installs` now returns
  `ForInstallsError::ZeroNetworkConcurrency` when `network_concurrency`
  is 0 instead of building a zero-permit semaphore that would hang every
  fetch. Matches pnpm, which rejects the value (p-queue requires a
  concurrency >= 1).
- When `npmrcAuthFile` is a bare filename with no parent, resolve its
  relative `cafile`/`certfile` entries against the process cwd (empty
  base path) rather than treating the file itself as the base directory.

* style(config): add trailing comma to multi-line format! (dylint)

Satisfies dylint's `perfectionist::macro-trailing-comma`, which the
pre-push hook enforces but `just ready` / clippy don't surface.

* style(config): fix "Unparseable" -> "Unparsable" typo in comment

Satisfies the Spell Check (crate-ci/typos) CI job.
2026-05-29 11:39:35 +02:00
Zoltan Kochan
aa3135b9f2 feat(pacquet): port resolution/versions settings (#12042) (#12052)
Port the "Resolution / versions" section of the pnpm-parity tracking
issue to pacquet.

Full behavioral wiring:

- `resolutionMode` (`highest` / `time-based` / `lowest-direct`): direct
  deps pick lowest under `time-based`/`lowest-direct`; `time-based`
  additionally constrains transitive deps to a publish-date cutoff
  (newest resolved direct dep + 1h, clamped by `minimumReleaseAge`),
  computed workspace-wide in a pre-pass.
- `registrySupportsTimeField`: gates the full-metadata fetch the
  `time-based` cutoff (and the `no-downgrade` trust policy) need —
  `(time-based || no-downgrade) && !registrySupportsTimeField`.

Config-surface parity (parsed + stored, consumed once the surrounding
feature lands, as pacquet already does for other not-yet-active
settings):

- `allowedDeprecatedVersions` (no deprecation warnings yet),
- `updateConfig.ignoreDependencies` (no `update`/`outdated` command yet),
- `peerDependencyRules` (no peer-issue reporting pass yet).

Also treat `minimumReleaseAge: 0` as disabled (matching pnpm's falsy
`opts.minimumReleaseAge ? ... : undefined` check). While a maturity
cutoff is active the picker always prefers the highest mature version,
so this is what lets `lowest-direct`/`time-based` take effect on direct
deps.
2026-05-29 11:39:16 +02:00
Juan Picado
a9011ad83b feat(registry): add logging support for requests (#12033)
Adds YAML-driven logging to `pnpm-registry`. Format (`pretty` or `json`) and level come from a `log:` block in `config.yaml`. Every HTTP request emits one structured access record (method, URI, status, latency) under the `pnpm_registry::access` tracing target — pino-shaped. When `-c` isn't passed, the global `config.yaml` is auto-discovered using the same per-OS rules pnpm uses for its own config dir, under a `pnpr` leaf.

```yaml
log:
  type: stdout
  format: json
  level: info
```

## What's in

- New `LogConfig` / `LogFormat` / `LogLevel` types; defaults to pretty/info.
- `init_logging` picks `.json()` vs `.compact()` from the resolved config; `RUST_LOG` still overrides. JSON keeps the request span's `method`/`uri` on each access record (`with_current_span(true)`).
- `TraceLayer` emits exactly one INFO access record per request — both the span and the completion event under `target: "pnpm_registry::access"`, with tower-http's default emissions suppressed — so `LogLevel::Http`'s filter directive can scope to them.
- Global-config auto-discovery follows pnpm's `getConfigDir` rules (`XDG_CONFIG_HOME` / macOS `Library/Preferences` / Windows `LOCALAPPDATA` / `~/.config`) under a `pnpr` leaf. That resolution is shared with `pacquet-config` via a new dependency-free `pacquet-config-dir` crate, parameterized by app-name leaf.
- Verdaccio 6+ shape (`log:`, singular). The older plural `logs:` is silently ignored.
- `log.type` values other than `stdout` parse (verdaccio compatibility) but are ignored at runtime with a startup warning; only `stdout` is implemented.

## Notes

- File/syslog sinks are future work.
- New crate: `pacquet-config-dir` (std-only, used by both `pacquet-config` and `pnpm-registry`). New `pnpm-registry` deps: `home` and `tracing-subscriber`'s `json` feature — both already in `[workspace.dependencies]`.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-29 00:12:07 +02:00
Alessio Attilio
eb48bfd6c1 feat(registry): add /-/ping endpoint and shared config env var substitution (#12036)
This PR adds two features to the pnpm private registry:

- **`/-/ping` endpoint** — implements the npm `ping` endpoint (`GET /-/ping`), returning an empty JSON object `{}` with `200 OK`, matching npm's and verdaccio's behavior. Usable as a liveness/health check by clients and orchestrators.
- **Environment variable substitution in config** — the registry's YAML config now expands `${VAR}` and `${VAR:-DEFAULT}` placeholders before deserialization.

### Shared env-var substitution with pacquet

Rather than reimplement placeholder expansion, the registry reuses pnpm's exact semantics. The `${VAR}` substitution logic that already lived in `pacquet-config` (a faithful port of `@pnpm/config.env-replace`) is extracted into a new standalone crate, **`pacquet-env-replace`**, which both `pacquet-config` and the registry depend on. This means the registry inherits pnpm's behavior for free:

- `${VAR:-default}` falls back when the variable is set-but-empty, not just when it's absent.
- `\${VAR}` backslash escaping is honored.
- Unresolved placeholders are surfaced (logged via `tracing::warn!`) instead of being silently emptied — mirroring pnpm's lossy fallback (the OIDC `${VAR}`-leak guard, [#11513](https://github.com/pnpm/pnpm/issues/11513)).

`pacquet-config` re-exports `EnvVar` and keeps threading its own `Host` provider, so its behavior is unchanged.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-28 23:54:23 +02:00
Zoltan Kochan
2cca6ab9df feat(package-manager): port packageExtensions setting (#12027)
Ports the `packageExtensions` setting from pnpm to pacquet, end-to-end. Refs pnpm/pnpm#12009.

## What lands

**Config + yaml plumbing** (`pacquet-config`):
- `PackageExtension` / `PeerDependencyMeta` structs (mirror pnpm's `Pick<BaseManifest, 'dependencies' | 'optionalDependencies' | 'peerDependencies' | 'peerDependenciesMeta'>`).
- `WorkspaceSettings::package_extensions` with yaml deserialization, `clear_workspace_only_fields` entry, and empty-map collapse (matches the `overrides` shape).
- `Config::package_extensions` field.
- `PNPM_CONFIG_PACKAGE_EXTENSIONS` env var.

**Hook** (`pacquet-package-manager::PackageExtender`):
- Port of pnpm's [`createPackageExtender`](https://github.com/pnpm/pnpm/blob/39101f5e37/hooks/read-package-hook/src/createPackageExtender.ts): groups extensions by package name once, then applies semver range filter and merges fields onto the manifest. Manifest fields win over extension fields on conflict (matches upstream's `{ ...extension, ...manifest }` spread order).
- `apply_to_arc` deep-clones the inner `Value` only when an extension matches — unrelated manifests keep sharing the resolver's cached `Arc`.

**Resolver wiring** (`pacquet-resolving-deps-resolver`):
- New `ManifestHook` type, threaded through `ResolveDependencyTreeOptions` / `ResolveImporterOptions` / `TreeCtx`.
- Applied in `resolve_node` right after `Resolver::resolve` returns and before the result enters the wanted-dep cache — same site as pnpm's [`ctx.readPackageHook(pkg)`](https://github.com/pnpm/pnpm/blob/39101f5e37/installing/deps-resolver/src/resolveDependencies.ts#L1481-L1483) call.
- `install_with_fresh_lockfile` builds the hook from `Config.package_extensions` once per install.

**Workspace state / drift check** (`optimistic_repeat_install`):
- `current_settings` now emits `package_extensions` so `.pnpm-workspace-state-v1.json` round-trips through pnpm.
- `settings_match` compares the field (treating empty map as absent for cross-implementation parity).
- Removed from the "deliberately not compared" list in `settings_match`.

**Lockfile checksum + frozen-install drift gate** (`pacquet-graph-hasher`, `pacquet-lockfile`):
- New `hash_object_nullable_with_prefix` in `pacquet-graph-hasher` — port of pnpm's [`hashObjectNullableWithPrefix`](https://github.com/pnpm/pnpm/blob/39101f5e37/crypto/object-hasher/src/index.ts#L44-L48), byte-for-byte identical (tests pin against the known `sha256-48AVoXIXcTKcnHt8qVKp5vNw4gyOB5VfztHwtYBRcAQ=` output upstream's test asserts).
- New `Lockfile::package_extensions_checksum` top-level field (matches upstream's wire shape).
- `install_with_fresh_lockfile` computes and writes the checksum on every fresh install; `current_lockfile` clones it into the materialized lockfile.
- `check_lockfile_settings` gained a `package_extensions_checksum` argument and a new `StalenessReason::PackageExtensionsChecksumChanged` variant. The check fires after `overrides` and before `ignoredOptionalDependencies` — matches upstream's ordering at [`getOutdatedLockfileSetting.ts:53-55`](https://github.com/pnpm/pnpm/blob/39101f5e37/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts#L53-L55) so the first-drift error reads the same on both sides.
2026-05-28 21:21:44 +02:00
Zoltan Kochan
a0eb2ad778 feat(pacquet): port injectWorkspacePackages setting (#12021)
Ports `injectWorkspacePackages` to pacquet end-to-end — config layer, resolver (global + per-dep), lockfile, and freshness gates — closing one of the eight settings tracked at #12009.

### What the feature does

When set globally, workspace-package resolutions materialize as `file:` (hard-linked copies into the virtual store) instead of `link:` symlinks back to the source. Per-dependency `dependenciesMeta[<name>].injected = true` opts a single dep into the same behavior even when the global flag is off.

### Changes

**Config layer (commit b3a68957f8):**
- Adds `inject_workspace_packages: bool` to `Config` (default `false`, matching pnpm's `'inject-workspace-packages': false`).
- Adds the field to `WorkspaceSettings` so it parses from `pnpm-workspace.yaml` (camelCase).
- Threads `config.inject_workspace_packages` into `ResolveOptions` at the install entry point.
- Joins it to the workspace-state freshness comparison: `current_settings` writes the value, `settings_match` compares it, a flip between runs invalidates the optimistic-repeat-install cache.

**End-to-end pipeline (commit 44e2ea918a):**
- `LockfileSettings.inject_workspace_packages` round-trips through the v9 lockfile. `false` omits the key on save, matching pnpm's [`lockfileFormatConverters.ts:70-72`](https://github.com/pnpm/pnpm/blob/39101f5e37/lockfile/fs/src/lockfileFormatConverters.ts#L70-L72).
- `dependencies_graph_to_lockfile` populates the new field from `Config.inject_workspace_packages`.
- `check_lockfile_settings` adds a Boolean-normalized drift gate for `settings.injectWorkspacePackages`, mirroring upstream's [`getOutdatedLockfileSetting.ts:80-82`](https://github.com/pnpm/pnpm/blob/39101f5e37/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts#L80-L82).
- `build_pkg_id_with_patch_hash` now prefixes file:/git:/tarball: resolutions with `name@` when the resolver didn't set `name_ver`, reading the name from the manifest — matching pnpm at [`resolveDependencies.ts:1502-1507`](https://github.com/pnpm/pnpm/blob/097983fbca/installing/deps-resolver/src/resolveDependencies.ts#L1502-L1507). Without this, file: dep paths end up unprefixed and `PkgNameVerPeer`'s `@`-split parser fails on the `@` inside the peer suffix. `link:` ids are deliberately exempted so the downstream `is_link` short-circuit still triggers.

**Cross-platform fix (commit 38a1db9cc1):**
- `PkgNameVerPeer::to_virtual_store_name` previously escaped only `/`, leaving `:` in `file:`-resolution slot names. NTFS / FAT reject these with `ERROR_INVALID_NAME (123)`, but APFS / ext4 accept them, so the bug only surfaced once a workspace-injected dep hit the Windows CI of this PR. The escape now covers the full Windows-invalid charset `[\\/:*?"<>|#]`, matching upstream's [`depPathToFilename` regex](https://github.com/pnpm/pnpm/blob/1819226b51/deps/path/src/index.ts#L169-L170).

**Per-dep `dependenciesMeta` thread (commit 7cc0f98bd7):**
- `importer_injected_dependency_names(manifest)` reads `dependenciesMeta` from the importer manifest and returns the names whose `injected` flag is `true`.
- The wanted-spec tuple in `extend_tree` / `resolve_catalog_specifiers` widens from `(alias, range, optional)` to `(alias, range, optional, injected)`. `extend_tree` lifts the spec's `injected` onto `WantedDependency::injected` as `Some(true)` / `None`, matching pnpm's `injected: opts.dependenciesMeta[alias]?.injected` shape so absent meta yields `None` (not `Some(false)`).
- `WantedKey` cache key gains a fourth slot for `injected` — the workspace branch of the npm resolver emits a `file:` resolution when injected and a `link:` one otherwise, so two importers asking for the same workspace dep with different per-dep flags must take different cache buckets.
- Importer-only scope: the child-spec walker keeps its 3-tuple shape; no resolved package's own `dependenciesMeta` is inherited. Hoisted-required / hoisted-optional peer arms default `injected` to `false` since they construct fresh wanteds without dep meta.

Refs #12009.
2026-05-28 21:03:45 +02:00
Zoltan Kochan
82e31d6627 feat(pacquet): port excludeLinksFromLockfile (#12025)
Port the `excludeLinksFromLockfile` setting to pacquet so its behavior matches the TypeScript pnpm CLI (which has had this setting since [pnpm/pnpm#6570](https://github.com/pnpm/pnpm/pull/6570)). Refs [pnpm/pnpm#12009](https://github.com/pnpm/pnpm/issues/12009).

When `excludeLinksFromLockfile: true` is set, pacquet now:

- Omits bare `link:` direct dependencies from each importer's `pnpm-lock.yaml` entry (keeping `workspace:`-resolved links recorded).
- Remaps external link targets to a stable `link:<rel-from-lockfile_dir-to-modules_dir>/<alias>` node id when seeding peer-resolution parents, so peer suffixes for snapshots that depend on a linked package don't carry the absolute path of the external link. The remap fires only when the link target is *outside* the lockfile root (per upstream's `isSubdir` gate).
- Round-trips the setting into the lockfile's top-level `settings:` block so `getOutdatedLockfileSetting` can spot drift on the next install.

## Where it's hooked (mirrors pnpm)

- **Config flag**: `pacquet-config` adds `exclude_links_from_lockfile: bool` (default `false`) — mirrors [`config/reader/src/Config.ts:71`](https://github.com/pnpm/pnpm/blob/094aa6e57b/config/reader/src/Config.ts#L71) and the [`false` default](https://github.com/pnpm/pnpm/blob/094aa6e57b/config/reader/src/index.ts#L144).
- **Importer entry**: `dependencies_graph_to_lockfile::build_importer` mirrors upstream's [`addDirectDependenciesToLockfile` exclude-link gate](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/index.ts#L449-L456) — drops `link:` direct deps from the importer entry unless their manifest specifier starts with `workspace:`.
- **Peer-resolution remap**: `resolve_peers::build_importer_parents` ports the [`target` rewrite at `index.ts:232-244`](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/index.ts#L232-L244). External `link:` parents are seeded into `ParentRefs` with a remapped node id (`link:<rel-from-lockfile_dir-to-modules_dir>/<alias>`), so the peer suffix stays stable across machines.
- **Peer-id translation**: `build_peer_id` special-cases `link:` node ids and emits `PeerId::Pair { name: peer_alias, version: link_path_to_peer_version(rel) }` — the exact port of upstream's [`peerNodeIdToPeerId` link arm](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/resolvePeers.ts#L984-L989).
- **Snapshot child fallback**: a peer-resolved child whose node id is `link:<rel>` and isn't in `node_dep_paths` uses the link node id verbatim as the snapshot child ref — mirrors upstream's [`pathsByNodeId.get(childNodeId) ?? (childNodeId as DepPath)`](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/resolvePeers.ts#L164) in `resolveChildren`.

## New shared helpers

- `pacquet-deps-path::link_path_to_peer_version` — faithful port of upstream's [`linkPathToPeerVersion.ts`](https://github.com/pnpm/pnpm/blob/094aa6e57b/installing/deps-resolver/src/linkPathToPeerVersion.ts). Iterates Unicode scalars (not raw bytes), so multi-byte UTF-8 path segments round-trip intact; pinned by a `non_ascii_path_segments_round_trip` test covering Latin-1, CJK, and a non-BMP emoji.
- `pacquet-fs::is_subdir` — promoted from a private `pacquet-cmd-shim` helper so the resolver and the cmd-shim share one implementation. Mirrors npm's [`is-subdir`](https://github.com/zkochan/packages/blob/main/is-subdir/index.js).
2026-05-28 19:16:51 +02:00
Zoltan Kochan
87136edaa3 fix(registry): verify tarball SHA on publish (#11976)
Closes #11975.

`pnpm-registry`'s `PUT /:pkg` endpoint accepted tarballs without verifying that their bytes matched the integrity declared in the packument. A buggy or partial-upload client could land a tarball whose hash didn't match `dist.integrity` / `dist.shasum`, and a malicious client could deliberately decouple the advertised hash from the bytes — both classes of silent-corruption bug that only surface when downstream `pnpm install` later fails with `EINTEGRITY`.

This change hashes every attachment before any I/O happens and rejects mismatches up-front so the bad bytes never reach the cache. The decode + hash + write runs in one streaming pass so the full decoded payload never lives in memory at once.

## What lands

- `stream_decode_verify_and_write` in `publish.rs` — pulls base64 chunks out of `base64::read::DecoderReader`, feeds each 64 KiB chunk to an ssri `IntegrityChecker` (SHA-512), an optional `IntegrityOpts(Sha1)` for legacy shasum, and the on-disk tmp file in lockstep. Verifies declared `length`, integrity, and shasum at the end; any failure removes the tmp file before returning.
- All failure modes surface as `400 BAD_REQUEST` with an `EINTEGRITY:` prefix in the body so pnpm / npm clients can recognize the error code.
- `extract_attachments` no longer base64-decodes eagerly; it returns `PendingAttachment { filename, data, declared_length }` and lets the streaming path consume `data` directly.
- `PackageName::parse_tarball_name` returns `(canonical, version)` so the publish handler can pull the matching `versions[v].dist` block out of the body. `canonicalize_tarball_name` becomes a thin wrapper.
- `Cache::reserve_tarball_paths` + `finalize_tarball_slot` expose a tmp/final path pair so the publish handler can do the actual write inside `tokio::task::spawn_blocking` (sync `std::fs`) and finalize via async `tokio::fs::rename` afterward.
- `publish_package` calls `stream_decode_verify_and_write` *before* any tarball lands at its final path. On any failure it removes every in-progress tmp file so a rejected publish leaves no on-disk artifact.

## Memory win

On a 100 MiB tarball publish, the old flow held the full payload in three places simultaneously (HTTP body Bytes + serde_json owned base64 String + decoded `Vec<u8>`). The streaming flow drops the decoded `Vec<u8>` entirely — only the base64 string and a 64 KiB working buffer remain. Roughly 100 MiB of peak heap saved per concurrent publish.
2026-05-28 02:20:25 +02:00
Zoltan Kochan
0cefccf158 feat(registry): persist pnpr users and tokens to disk (#11977)
* feat(registry): persist pnpr users and tokens to disk

Backs UserStore with a verdaccio-shaped htpasswd file (bcrypt $2y$
hashes, atomically rewritten on every adduser) and TokenStore with a
SQLite database that stores SHA-256 token hashes plus the per-record
fields the upcoming /-/npm/v1/tokens surface will need (created_at,
last_used_at, readonly, cidr_whitelist).

Configuration mirrors verdaccio's auth.htpasswd.{file,max_users}
under the existing YAML schema; tokens default to a tokens.db
sibling of htpasswd, overridable via auth.tokens.file. max_users=-1
disables registration end-to-end. Both files are written via
tmp+rename and loaded eagerly on startup so a malformed htpasswd
fails fast rather than booting with a silent empty user list.

Closes #11974.

* fix(registry): use OS CSPRNG, satisfy dylint + rustdoc

- TokenStore's per-process secret now comes from getrandom (OS-backed
  CSPRNG) instead of time/pid/stack address. Tokens are derived from
  this secret + a per-issue nonce, so weak entropy was making mint
  outputs guessable to an attacker who could bound those inputs.
- Reorder derives on AuthConfig / HtpasswdConfig / TokensConfig /
  MaxUsers to satisfy perfectionist::derive-ordering (prefix-then-
  alphabetical: Debug, Default first, then the rest).
- Re-export auth::identify so the rustdoc link from the now-public
  UserStore::verify resolves; rustdoc::private-intra-doc-links no
  longer fails the workspace doc build.
- Drop the inaccurate "+inf" mention from MaxUsers' doc — serde-saphyr
  treats +inf as a float and can't deserialize it into i64, so the
  only way to get Unlimited is to omit max_users.
2026-05-28 01:30:42 +02:00
Juan Picado
f03dc2d15d refactor(registry): adopt verdaccio-shaped YAML config (#11970)
Reshape pnpm-registry's Config to match verdaccio's `config.yaml` schema (storage, uplinks, packages) so the same file can drive either server. The previous Config exposed a single `upstream: Option<String>` resolved at startup; this replaces it with named uplinks plus per-package `proxy:` rules walked in declared order — same semantics as verdaccio, minus the surface pnpm-registry does not implement (auth, web, plugins, middlewares, logs routing, secret), which are accepted and ignored.

Highlights:

  * `Config { listen, public_url, storage, uplinks, packages, packument_ttl }` with `UplinkConfig { url }` and `PackageAccess { access, publish, unpublish, proxy }`. `packages` is an `IndexMap` walked in declared order, first-match-wins: the first pattern matching a request is the rule that applies, and if that rule has no `proxy:` the package is storage-only (resolution returns `None` instead of falling through to a later catch-all). That makes the bundled `@private/*` / `@pnpm.e2e/needs-auth` / unscoped-fixture rules behave the way the YAML says they should.
  * `Config::from_yaml(path, ...)` loads via `serde-saphyr` and resolves a relative `storage:` against the config file's parent. The verdaccio-only sections in the YAML are skipped silently so `registry-mock`'s upstream `config.yaml` parses untouched.
  * `DEFAULT_CONFIG_YAML` — the bundled file mirrored from `@pnpm/registry-mock` — is `include_str!`-ed and re-exported from `lib.rs` so other crates (tests, benchmarks, future embedders) can use the same defaults without reading from disk. `Config::from_default_yaml(base_dir, ...)` parses it.
  * CLI: `-c` / `--config <path>` overrides the bundled default. `--storage` survives as a runtime override (handy for tests without a custom YAML); `--upstream` and `--static` are gone because the YAML now drives both. `--packument-ttl-secs` is optional — the loaded config's value wins when the flag is not supplied. The mock orchestrator at `pacquet/tasks/registry-mock` drops its `--upstream` flag — that command spawns the locally-built binary so it tracks this PR's source directly. The jest harness at `__utils__/jest-config/with-registry/globalSetup.js` keeps `--upstream` for now because CI installs `@pnpm/pnpr` from npm; the flag will be dropped in the same PR that bumps `@pnpm/pnpr` to a build that lacks it.
  * `server.rs` pre-builds one `Upstream` per declared uplink at router construction (keyed by name, in an `IndexMap`) and resolves the right client per request via `Config::resolve_uplink`. No per-request `ThrottledClient` allocations.

Existing tests are kept working by retaining the `Config::proxy` / `Config::static_serve` constructors and switching the test helper from `config.upstream = ...` to mutating
`config.uplinks["npmjs"].url`. All 107 tests in `pnpm-registry` pass (55 unit + 26 + 9 + 17 integration).

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

* **New Features**
  * YAML-based registry configuration with package-level routing, multiple uplinks, auth and audit middleware
  * New --config option to load custom registry configs; optional storage override and packument TTL setting

* **Chores**
  * Default registry now uses the bundled configuration (web UI disabled by default)
  * Configuration refactor to support Verdaccio-style routing patterns and per-package access/publish rules

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-26 23:52:37 +02:00
Zoltan Kochan
ad84fffd46 fix: reject path-traversal segments in dependency aliases (#11954)
* fix: reject path-traversal segments in dependency aliases

A transitive registry package can use a dependency-alias key like
`@x/../../../../../.git/hooks` to make `pnpm install` create a symlink
outside the intended `node_modules` directory, since pnpm passes the
alias straight into `path.join(modulesDir, alias)` without checking
that the joined path stays inside `modulesDir`.

Reject aliases that aren't a single `name` or `@scope/name` shape at
manifest-read time (both the importer's manifest and every transitive
package manifest) and re-check at the symlink layer as defense in
depth. Mirror the fix in pacquet's deps-resolver.

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

* fix(pacquet): use raw strings in alias validator tests for dylint

Perfectionist's `prefer-raw-string` lint rejects the two
backslash-escaped test inputs.

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

* refactor: tighten dependency-alias validator to validate-npm-package-name

An alias is the directory name pnpm creates inside `node_modules`, so
the only valid shapes are a single `name` or `@scope/name` consisting
of URL-friendly characters with no leading `.` / `_`, and not equal to
reserved names such as `node_modules`. That's the same
`validForOldPackages` rule `parseWantedDependency` already applies to
CLI-given names — the manifest-read path should match. Route both
stacks through it so `.bin`, `.pnpm`, `node_modules`, `favicon.ico`,
whitespace, and non-URL-friendly characters are all rejected alongside
the path-traversal shapes the narrow validator caught.

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

* refactor: collapse symlink-layer assertion + path.join into safeJoinModulesDir

The two-step pattern of "assert the alias stays in the dir" then "join
the dir and the alias" left it possible for a caller to use the join
without the assertion. Fold them into a single `safeJoinModulesDir`
that returns the joined path and throws on escape, so the check is
unmissable.

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

* test(symlink-dependency): cover the path-equals-dir guard branch

The earlier tests only exercised the `!startsWith` branch with
`'../sibling'` and `'@x/../../../etc'`. Add `''` and `'.'` as alias
cases — both resolve to the modules dir itself and hit the
`resolvedLink === resolvedDir` branch of `safeJoinModulesDir`.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-26 17:25:25 +02:00
Zoltan Kochan
1fb8a2d5d8 perf(pacquet): unlock no-op short-circuit + port abbreviated-modified verifier shortcut (#11931)
Two fixes that together unlock pnpm-parity on the
`benchmarks.vlt.sh` `lockfile+node_modules` shape — the row where
pacquet was 2-12× slower than pnpm on every fixture.

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

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

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

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

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

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

## Bench

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

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

3.0× faster on the `lockfile+node_modules` row.
2026-05-25 17:00:16 +02:00
Nicolas Le Cam
e52e4fce63 feat(pacquet): port detect-libc to Rust and replace ad-hoc libc detection in graph-hasher (#11921)
* refactor(graph-hasher): replace ad-hoc libc detection with pacquet-detect-libc

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

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

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

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

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

* fix(detect-libc): use from_utf8_lossy for command output, fix lints and tests
2026-05-25 15:32:11 +02:00
Zoltan Kochan
d579e6cbb5 perf(pacquet): trim install-phase syscalls and allocations (#11864)
* perf(fs,package-manager): striped CAS lock + skip pre-flight stat on fresh-target imports

Two install-phase syscall trims:

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

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

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

Refs #11857, #11851.

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

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

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

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

Refs #11857.

* docs(pacquet): address CodeRabbit nits

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

* docs: forbid past-implementation history in comments

- Extend AGENTS.md Comments rules: comments must describe the
  current contract, not what the code replaced. Phrasings like
  "used to", "previously", "the original X", or parentheticals
  naming a removed type belong in `git log`.
- Apply the rule to `cas_write_lock`'s doc, which previously
  framed itself in terms of the removed
  `DashMap<PathBuf, Arc<Mutex<()>>>` shape.
2026-05-25 14:36:05 +02:00
Zoltan Kochan
ac299aa0e5 fix(pacquet,package-manager): walk every workspace project in fresh-resolve install (#11905)
* fix(package-manager): walk every workspace project in fresh-resolve install

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

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

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

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

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

Closes #11901.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two-part fix matching upstream:

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

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

* style: cargo fmt

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

Two issues:

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

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

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

   Matches Go's `path.Clean` semantics. New test
   `lexical_normalize_handles_parent_dir_corner_cases` pins all four
   corner cases.
2026-05-25 13:11:53 +02:00
Zoltan Kochan
d8a79a9c30 feat(registry): add auth/dist-tag/publish endpoints + wire TS tests onto pnpm-registry (#11914)
Lands the pieces of the npm registry protocol that pnpm-registry was missing, and switches the TypeScript test harness off verdaccio onto pnpm-registry. `@pnpm/registry-mock` (the npm package) is untouched.

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

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

### TypeScript-test migration

- `__utils__/jest-config/with-registry/globalSetup.js` keeps `prepare()` from `@pnpm/registry-mock` (still needed for the tempy storage path written into the runtime-config yaml — `getIntegrity` reads it from there) but spawns `pnpm-registry` instead of verdaccio. `addUser`, `addDistTag`, `getIntegrity`, `REGISTRY_MOCK_*` from registry-mock work as-is — they're plain npm-wire-protocol HTTP calls.
- Binary lookup follows pacquet's pattern: `PNPM_REGISTRY_BIN` env override, then `target/release/pnpm-registry`, then `target/debug/pnpm-registry`.
- CI test job (`.github/workflows/test.yml`) installs the Rust toolchain via the existing `./.github/actions/rustup` composite action and builds `pnpm-registry --release` before tests run. Per-platform — Linux and Windows in the matrix each build their own.
2026-05-25 09:40:09 +02:00
Zoltan Kochan
add6c794f1 feat(registry): implement pnpm-registry server and adopt it in pacquet's test mock (#11898)
Creates a working pnpm-compatible npm registry server (verdaccio analogue, in Rust) — and replaces `@pnpm/registry-mock`'s Node + Verdaccio launcher in pacquet's test setup with the new binary, against `@pnpm/registry-mock`'s shipped storage.

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

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

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

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

### Crates.io name reservations (from the original scaffold commit)
- [`pnpm-registry`](https://crates.io/crates/pnpm-registry) — published from this repo
- [`pnpm-registry-cli`](https://crates.io/crates/pnpm-registry-cli) / [`pnpm-registry-server`](https://crates.io/crates/pnpm-registry-server) — placeholder stubs, name reservation only
2026-05-24 21:18:09 +02:00
dependabot[bot]
7a5cb92f80 chore(cargo): bump assert_cmd from 2.2.1 to 2.2.2 (#11853)
Bumps [assert_cmd](https://github.com/assert-rs/assert_cmd) from 2.2.1 to 2.2.2.
- [Changelog](https://github.com/assert-rs/assert_cmd/blob/master/CHANGELOG.md)
- [Commits](https://github.com/assert-rs/assert_cmd/compare/v2.2.1...v2.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 21:21:20 +02:00
Zoltan Kochan
5353fcbf01 perf(pacquet): close the warm-cache resolve gap to pnpm CLI (#11837)
Closes #11832.

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

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

## What's in this PR

### Resolve-phase

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

### Install-phase

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

### Correctness

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

### Diagnostics

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

### Review cleanup

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

### Doc + Dylint fixes

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

## Scope

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

## Follow-ups

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

## Benchmark

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

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

The remaining ~0.87s gap is concentrated in `resolve_importer` and is what #11843 targets.
2026-05-22 02:15:53 +02:00
Zoltan Kochan
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
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
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
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
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
c068720dec fix(pacquet): shorten long virtual store dirnames to avoid ENAMETOOLONG (#11768)
* fix(pacquet): shorten long virtual store dirnames to avoid ENAMETOOLONG

Peer-heavy snapshot keys (e.g. vitest with a dozen browser / coverage /
DOM peers) produced flat-name directories that overflowed macOS's 255-
byte filename limit, so `install` aborted with errno 63 before unpacking
any tarballs. Port the trailing length / case-shortening branch of
upstream's `depPathToFilename` (deps/path/src/index.ts:169) so the name
becomes `<prefix>_<32-hex-sha256>` capped at `virtualStoreDirMaxLength`
bytes (default 120).

Extract `create_short_hash` and `shorten_virtual_store_name` into a new
`pacquet-crypto-hash` crate mirroring upstream `@pnpm/crypto.hash`;
`pacquet-lockfile`, `pacquet-registry`, and `pacquet-store-dir` all
consume it instead of duplicating the sha2 + truncate logic.

Reported via pnpm/pacquet issue triage (vitest@4.1.6 peer suffix).

* fix(pacquet): taplo format and remove broken intra-doc link

Format `pacquet/crates/crypto-hash/Cargo.toml` per the workspace
`.taplo.toml` (aligns the `[package]` keys) and downgrade the
`pacquet_modules_yaml::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH` reference
in `PkgNameVerPeer::to_virtual_store_name` to plain text, since
`pacquet-lockfile` deliberately does not depend on
`pacquet-modules-yaml` and `RUSTDOCFLAGS=-D warnings` rejected the
unresolved intra-doc link.

* feat(pacquet/config): expose virtualStoreDirMaxLength

Add `virtual_store_dir_max_length: u64` to `Config` with default 120
(matching `pacquet_modules_yaml::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH`).
Wire it through `WorkspaceSettings.virtualStoreDirMaxLength` and the
`PNPM_CONFIG_VIRTUAL_STORE_DIR_MAX_LENGTH` env-overlay so users can
override the threshold via `pnpm-workspace.yaml`, global `config.yaml`,
or environment variables — mirroring upstream
`Config.virtualStoreDirMaxLength`.

The three flat-name call sites (`install_without_lockfile.rs`,
`install_package_from_registry.rs`, `virtual_store_layout.rs`) and the
`.modules.yaml` writer now read the configured value instead of the
hardcoded constant. `VirtualStoreLayout::legacy` takes the value as an
explicit second arg so test fixtures don't silently inherit a default.
2026-05-20 15:39:57 +02:00
Zoltan Kochan
097983fbca feat(pacquet): wire NpmResolver into install; fix(pick-registry) unscoped npm-alias routing (#11760)
Two changes ship together: the bulk is the pacquet refactor described in #11756, plus a TypeScript-side fix to `@pnpm/config.pick-registry-for-package` that surfaced during review.

### Pacquet — wire NpmResolver into install (Phases A/B/C of #11756)

- **Phase A.** New `parse_bare_specifier.rs` and `npm_resolver.rs` in `pacquet-resolving-npm-resolver`. `NpmResolver` implements the `Resolver` trait: parses the bare specifier (including npm-alias `npm:@scope/name@<spec>` and tarball-URL forms — with prefix-anchored name validation), picks a version via `pick_package`, surfaces `minimumReleaseAge` violations inline via `detect_min_release_age_violation`. `workspace:` specs decline so the chain falls through. `published_by` / `published_by_exclude` / `dry_run` added to `ResolveOptions`.
- **Phase B.** `install_without_lockfile.rs` constructs an `NpmResolver` at install entry from the config-derived registries map and an `InMemoryPackageMetaCache` that's shared across the resolve pass and dropped before the install pass.
- **Phase C.** New `pacquet-resolving-deps-resolver` crate exposes `resolve_dependency_tree`: a flat `name@version`-keyed package map with parent-child edges, concurrent sibling resolution via `try_join_all`, per-id dedup gate. `install_package_from_registry.rs` no longer calls `Package::fetch_from_registry` / `Package::pinned_version`; it takes a pre-resolved `ResolveResult` and reads tarball URL + integrity off `LockfileResolution::Tarball`.

Additional behaviors landed during review:

- **`minimumReleaseAge` policy in the resolve pass.** Previously only enforced by the lockfile-verification gate; the no-lockfile resolve pass now derives `published_by` and the exclude policy from `Config` so resolver-time picks match the configured policy.
- **`SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER` surfaces correctly.** `resolve_dependency_tree` now returns a typed error when the chain returns `Ok(None)` — silently dropping the edge would leave installs missing transitive deps. Mirrors upstream's `default-resolver` error shape.
- **Per-package progress events.** `InstallPackageFromRegistry` takes a `first_visit: bool`; `pnpm:progress resolved` / `pnpm:progress imported` plus the tarball download fire once per `(name, version)`, while the per-parent `symlink_package` runs on every edge. Matches upstream's per-package (not per-edge) reporter contract.
- **Windows symlink race fix.** `ResolvedPackages` is now `DashMap<String, watch::Sender<bool>>`; the first writer signals completion after `import_indexed_dir`, so a second visitor's `symlink_package` (which may fall back to a Windows junction requiring an existing target) doesn't race ahead of the materialization. A dropped first-writer task surfaces as a typed `FirstWriterAborted` error.
- **Scope routing.** `pick_registry_for_package` is now bareSpecifier-aware so an entry like `"foo": "npm:@acme/bar@^1"` routes through `registries[@acme]`.

### TS — `@pnpm/config.pick-registry-for-package` unscoped-target fix

A separate bug surfaced during the scope-routing port: `pickRegistryForPackage('@private/foo', 'npm:lodash@^1')` was routing through `registries['@private']`, even though `lodash` is unscoped and doesn't live on the `@private` registry. `getScope` now returns `null` in the npm-alias branch when the alias target is unscoped (instead of falling through to the local pkgName's scope). Changeset is in `.changeset/pick-registry-unscoped-npm-alias.md` (patch bump for `@pnpm/config.pick-registry-for-package` and `pnpm`). Added matching tests on both the TS and pacquet sides.

### Out of scope (left as #11756 follow-ups)

- Preferred-versions harvesting from the lockfile (Phase D).
- Install-side aggregation of `policy_violation` from the tree (Phase E) — the resolver attaches them per-pick already, but the install layer doesn't yet collect or fail on them.
- Other-protocol resolvers (git, tarball, workspace, jsr, named-registry, …) — `NpmResolver` is the only chain entry today; once a second resolver lands, `DefaultResolver` will get wired in too.
- Full `parseBareSpecifier.test.ts` corpus port — the parser tests pacquet ships cover the cases the install path exercises; remaining corpus items land alongside Phase F.

Closes part of #11756.
2026-05-20 14:43:21 +02:00
Khải
df77f649ee fix(pacquet/fs): serialize concurrent CAS writes to the same path (#11758)
* fix(pacquet/fs): serialize concurrent CAS writes to the same path

Two install snapshots whose tarballs ship identical file content
(e.g. a shared LICENSE across sibling packages like
`@pnpm.e2e/hello-world-js-bin` and `@pnpm.e2e/hello-world-js-bin-parent`)
hash to the same CAS path and call `ensure_file` concurrently. Without
serialization the second writer's `O_CREAT|O_EXCL` hits `AlreadyExists`
while the first writer is mid-`write_all`, falls into
`verify_or_rewrite`'s `meta.len() != content.len()` arm, and runs
`write_atomic` — which renames a temp file over the live source.
On Linux/ext4 the partial-size observation is fast enough that this
window opens often, surfacing as flaky CI failures of the form:

    failed to import "<store>/v11/files/65/<hash>" to ".../LICENSE":
    No such file or directory (os error 2)

emitted by `link_file` whose `reflink`/`fs::hard_link` raced the
rename. macOS/APFS and Windows tests pass because their `stat` cadence
and CI runner parallelism don't reliably open the window.

Port pnpm v11's `locker: Map<string, number>` semantics — slightly
stronger in pacquet, as a per-path `Mutex` rather than a dedup cache —
so two writers of the same CAS path serialize through it. The second
caller acquires the lock after the first writer's `write_all` has
finished, then takes the byte-match fast path inside `verify_or_rewrite`
and never has to rewrite. The previous docstring at the bottom of the
"Differences from pnpm" list explicitly acknowledged the locker
omission and predicted it would matter; this change closes that gap.

Add a regression test (`concurrent_writers_of_same_path_do_not_swap_the_inode`)
that fires 32 threads at one path with identical content and asserts
the inode never swaps — the observable signal that no writer ever took
the `write_atomic` rename path.

* fix(pacquet/fs): unlink intra-doc references to private items

`ensure_file` is public; references to private `verify_or_rewrite`,
`write_atomic`, and `cas_write_lock` only resolve under
`--document-private-items`, which trips `rustdoc::private-intra-doc-links`
under `-D warnings`. Drop the link form and keep the names as plain
backticked identifiers — the docstring still reads correctly and
`cargo doc` no longer fails.

* style(pacquet/fs): rename single-letter N to WRITER_COUNT in test

Address review feedback on #11758 — single-letter constants are
opaque; `WRITER_COUNT` reads as the loop count of concurrent
writers the test fires at one CAS path.

* docs(pacquet/fs): tighten ensure_file / cas_write_lock / test docs

Address review feedback on #11758: drop body-narrating prose, keep
only the contract and the non-obvious why. The test docstring no
longer references "pre-fix code" so it reads independent of this
PR's history.

* test(pacquet/fs): pre-create + per-thread inode capture in concurrent test

Address CodeRabbit's and Copilot's review feedback on #11758. The
previous assert compared two metadata reads taken at the same moment
after join — tautological. Pre-creating the file gives an
`original_ino` reference taken before the contended run, and each
writer also captures the path's inode immediately after its own
`ensure_file` returns so a mid-run rename swap from another writer
is visible to at least one of those observations.

* style(pacquet/fs): add trailing comma in multi-line assert! macro

Address `perfectionist::macro_trailing_comma` warning surfaced by
the Dylint CI on #11758 — multi-line macro invocations must end with
a trailing comma. (My earlier local `dylint --all` run missed this
because the perfectionist library hadn't fully recompiled against the
new test code.)

* test(pacquet/fs): drop pre-create from concurrent writer test

Address Copilot review feedback on #11758. The pre-create made every
contender take the byte-match fast path against a complete file, so
the lock made no observable difference and the test couldn't even
weakly distinguish lock from no-lock. Removing it leaves the
fresh-dirent shape (`O_CREAT|O_EXCL` race + verify_or_rewrite for
the rest), and the per-thread inode observations can now diverge
under a multi-rename race without the lock.

Honest about the limitation in the docstring: any observation taken
after `ensure_file` returns has already missed the rename window, so
a single-rename race converges on one inode and slips past. The
test catches the multi-rename case and validates the "no deadlock,
all writers see correct content" baseline.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 11:57:00 +02:00