Commit Graph

11537 Commits

Author SHA1 Message Date
Zoltan Kochan
d08e9abfb9 fix(pacquet/lockfile): accept link: refs in snapshot dependencies (#11788)
* fix(lockfile): accept link: refs in snapshot dependencies

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

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

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

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

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

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

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

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

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

* chore: drop changeset

Pacquet changes don't get changesets.

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

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

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

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

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

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

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

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

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

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

No behavior change; tests pass.

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

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

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

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

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

Reported by CodeRabbit on PR #11784.

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

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

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

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

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

Mirrors upstream pnpm/pnpm@b61e268d57:

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

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

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

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

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

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

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

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

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

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

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

The fix is three layered changes to `apply_patch_to_dir`:

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

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

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

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

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

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

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

* fix(pacquet): satisfy doc + dylint CI

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

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

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

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

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

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

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

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

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

Adds a `fail_when_resolving_missing_tarball_with_file_protocol` test
to pin the contract.

Resolves a CodeRabbit review comment on #11778.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore(pacquet): drop unused UnsupportedInstallResolution after rebase

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

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

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

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

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

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

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

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

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

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

* fix(pacquet): address PR review feedback

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Wires the parser into the npm resolver:

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

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

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

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

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

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

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

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

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-20 21:16:01 +02:00
Zoltan Kochan
11a43b15da chore(release): 11.2.1 (#11777) v11.2.1 2026-05-20 16:51:13 +02:00
Zoltan Kochan
2061c55b2a fix(env-installer): mark optional config subdep snapshots with optional: true (#11770)
Match how optional packages are recorded elsewhere in pnpm-lock.yaml so
non-host platform variants pulled in via a config dep's optionalDependencies
aren't treated as required.
2026-05-20 15:40:18 +02:00
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
e5e7b7241d fix(env-installer): suppress 'Installing config dependencies...' on no-op installs (#11766)
* fix(env-installer): only print "Installing config dependencies..." when work is actually being done

Previously the message was emitted unconditionally for every config
dependency, before any of the "do we need to fetch / re-symlink?"
checks. As a result the banner printed on every install even when
everything was already cached and correctly linked.

Emit the started event lazily — at most once per install, and only
when an orphan is being removed, a parent or subdep needs fetching,
a parent symlink needs (re)creating, or orphan subdep siblings are
being pruned.

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

* test(env-installer): assert installing-config-deps events fire only when work happens

Captures `streamParser` events around `resolveAndInstallConfigDeps`
to verify the lazy emission introduced in the previous commit:
- fresh install emits both `started` and `done`,
- a follow-up no-op install emits neither,
- removing a config dep still emits `started` (orphan cleanup work).

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

* test(env-installer): subscribe to streamParser once at module load

`streamParser` is a `split2` Transform stream that buffers writes until
the first 'data' listener attaches and then drains the whole buffer into
it. Subscribing per-test made the new install-config-deps test capture
events from every earlier test in the file. Move the subscription to
module load and have each test drain the accumulated events around its
own call.

Also drop the "removal" assertion: `resolveAndInstallConfigDeps` does
not prune entries that disappear from the configDeps argument (lockfile
pruning happens at a higher layer), so the scenario it claimed to test
never actually fired the orphan-cleanup path.

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

* fix(env-installer): emit started when only the sibling symlink needs relinking

If a config dep's optional subdep is already cached in the global
virtual store but the sibling symlink under the parent's node_modules
is missing or points at a stale target, symlinkDir() does real work
without reportStarted ever firing. Check whether the link already
points at the expected target and only fire reportStarted + symlinkDir
when it doesn't, mirroring the parentSymlinkAlreadyCorrect path.

Also clean up the test-level streamParser listener in afterAll so the
subscription doesn't outlive the test file.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-20 15:39:30 +02:00
Zoltan Kochan
ef87f3ccff test(pnpm): group release-brittle tests under a shared describe block (#11767)
Three tests resolve the running pnpm version's integrity from registry-mock,
which proxies pnpm to npmjs. They fail every release between the version
bump commit and the matching npm publish ("No matching version found for
pnpm@<version>"), then pass again once the version lands on npmjs. Group
them under a 'release-brittle' describe in each file so the failure mode
is obvious from the test name and only stated once.
2026-05-20 14:57:08 +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
Zoltan Kochan
0fb723323f chore(release): 11.2.0 (#11764) v11.2.0 2026-05-20 12:41:09 +02:00
Zoltan Kochan
4f56179d48 chore: update pnpm-lock.yaml (#11761)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-20 12:35:15 +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
Zoltan Kochan
3a54205178 feat(pacquet): resolver scaffold + npm version picking (#11755) 2026-05-20 07:20:07 +02:00
Zoltan Kochan
ced20cbe71 fix(config/reader): only sync registries.default to registry when workspace contributes it (#11754)
The sync introduced in #11744 unconditionally overwrote the unnormalized
registry value parsed from .npmrc with the normalized registries.default,
causing a trailing slash to appear in config.registry when the user only
configured a registry in .npmrc.

Restrict the sync to cases where pnpm-workspace.yaml actually contributes
a default registry different from what .npmrc provided.
2026-05-20 01:06:52 +02:00
Tim Haines
04a2c9c610 test(outdated): add regression test for minimumReleaseAge (#11699)
Adds a regression test for #11698. The underlying fix already shipped as part of #11705 (which removed `strictPublishedByCheck` entirely and routed maturity decisions through `policyViolation`), so this PR now lands only the dedicated test that locks in the behavior.

## What the test covers

`deps/inspection/commands/test/outdated/minimumReleaseAge.test.ts`:

- **Baseline** — without an age policy, `pnpm outdated` reports `is-negative@2.1.0` as an available upgrade (sanity check that the fixture actually has outdated deps).
- **Regression** — with `minimumReleaseAge` set to a cutoff so far in the past that every published version is immature, `pnpm outdated` reports nothing: `exitCode === 0` and `2.1.0` does not appear. Before #11705 this test went red because the non-strict resolver fallback re-picked the immature `latest` ignoring `publishedBy`.

The `allImmatureMinimumReleaseAge = Date.now() / (60 * 1000)` trick (cutoff = epoch in minutes) is date-independent and matches the technique already used in the install-side `minimumReleaseAge` suite.

## Why a test-only PR

The original PR proposed flipping `strictPublishedByCheck` in `createManifestGetter`, but #11705 deleted that option entirely and replaced it with an always-defer model (`policyViolation` flows through `ResolveResult` → `getManifest` returns `null` on `MINIMUM_RELEASE_AGE_VIOLATION`). The test was the durable contribution; preserving it as a regression gate is worth keeping.
2026-05-20 01:02:49 +02:00
shiminshen
3687b0e180 fix(config/reader): resolve relative cafile path against the .npmrc directory (#11726)
* fix(config/reader): resolve relative cafile path against the .npmrc directory

`cafile=<relative-path>` in `.npmrc` was being read via `fs.readFileSync`,
which resolves relative paths against `process.cwd()`. When pnpm is invoked
from a different cwd than the project (e.g. `pnpm --dir <project> install`
in CI wrappers and monorepo scripts), the CA file silently failed to load:
the `try/catch` in the loader dropped the CA list, the install proceeded
without the configured CA, and the user only saw TLS errors against a
private registry — with no log line tying back to the wrong path.

Resolve relative `cafile` values in `readAndFilterNpmrc` against
`path.dirname(filePath)` of the .npmrc that declared the key, before
`loadCAFile` reads the file. Absolute paths (the dominant CI shape) and
CLI `--cafile` are unchanged.

Ref: #11624

* refactor(config/reader): tighten cafile-fix comments

The test name and the linked issue already describe the failure mode,
so the 4-line preamble on the test and the 5-line in-line comment on
the implementation were re-narrating what the tests document.

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

* fix(pacquet/config): resolve relative cafile path against the .npmrc directory

Pacquet's `NpmrcAuth::from_ini` used to store the `cafile=` value
verbatim and pass it to `std::fs::read_to_string` at apply time. A
relative path therefore resolved against the process cwd, so a
project `.npmrc` containing `cafile=certs/ca.pem` reached via
`pacquet --dir <proj>` from a different cwd silently failed to load
the CA — same failure mode as pnpm/pnpm#11624 on the TypeScript side,
which the parent commit fixed by resolving against `path.dirname` of
the `.npmrc`.

Mirrors the parent commit on the pacquet side:

- `NpmrcAuth::from_ini` now takes the directory the `.npmrc` was
  loaded from. A relative non-empty `cafile=` value is resolved
  against that directory via `npmrc_dir.join(...)`; empty and
  absolute values pass through unchanged.

- `Config::current` tracks which of `start_dir` / home dir actually
  provided the `.npmrc` text and passes that path through.

- The `load_cafile` doc comment that documented "matches pnpm's
  surprising cwd-resolution behavior" is gone; that caveat was
  current only as long as pnpm itself had the bug.

- Existing tests updated mechanically to pass `Path::new("")` for
  the new parameter; four new tests cover the resolution branches
  (relative resolves, absolute passes through, empty passes through,
  end-to-end load via `apply_to` with a real tempdir-based fixture).

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

* fix(pacquet/config): add trailing commas inside multi-line assert_eq! macros

`perfectionist::macro-trailing-comma` is enforced via dylint at CI and
ran clean before the cafile port. Rustfmt reflowed two `assert_eq!`
calls in `parses_strict_ssl_true_and_false` onto multiple lines when
the `Path::new("")` argument made the line too long, but did not add
the trailing comma the dylint rule wants on the last macro argument.

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

---------

Co-authored-by: shiminshen <16914659+shiminshen@users.noreply.github.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-20 00:32:18 +02:00
Santiago
a62055786b fix: handle minimumReleaseAge policy violations in global installs (#11753)
* fix: handle release-age policy in global installs

* refactor: dedupe global policy-callback wiring

Collapse setupPolicyHandlers + createResolutionPolicyManifestUpdater into
one createGlobalPolicyCallbacks helper used by both global add and global
update entry points.

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

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-20 00:31:54 +02:00
Eyal Mizrachi
9cb48bb27b fix: injectWorkspacePackages crashes — lean resolution defense-in-depth + lifecycle re-import (#11662)
Fixes two **independent** crashes hitting `pnpm install --frozen-lockfile` on workspaces with `injectWorkspacePackages: true` (or `dependenciesMeta.*.injected`), surfaced via `turbo prune --docker` pipelines.

## Bug 1 — peer-variant snapshot missing `resolution` (lean, defense-in-depth)

A peer-variant injected workspace snapshot (`@scope/pkg@file:packages/pkg(peerA@1)(peerB@2)`) inherits its `resolution` from the base `packages:` entry (`@scope/pkg@file:packages/pkg`). When a tool prunes the lockfile and drops that base entry, readers that deref `pkgSnapshot.resolution` crash with the cryptic:

```
Cannot use 'in' operator to search for 'directory' in undefined
```

**The root cause is upstream of pnpm**: the pruner (e.g. `turbo prune`) emits an internally inconsistent lockfile. Fixed at the source in **vercel/turborepo#12825** (retain the base entry for peer-variant injected deps; minimal repro in **vercel/turborepo#12824**) — empirically verified to produce a correct pruned lockfile for a real multi-service workspace.

**pnpm side (this PR): one lean normalization at the read layer** — in `convertToLockfileObject`, where base→variant inheritance already happens via `Object.assign`. When the base entry is absent, reconstruct the directory resolution from the `file:` depPath. This is *reconstruction, not guessing*: for a workspace `file:` dep the directory **is** the depPath suffix — exactly what pnpm's own writer emits. It is **defense-in-depth, not load-bearing**: with a well-formed lockfile (turbo#12825 or any correct input) the branch never fires. Because the normalization sits at the single shared read layer, it also covers the sibling `Cannot use 'in' operator … 'integrity' in undefined` on the `pnpm deploy` path (same `resolution === undefined` root, different deref site).

Per review feedback: the earlier per-reader `inheritOrSynthesizeResolution` helper across 5 call sites is **removed**; normalization lives in exactly one place (`convertToLockfileObject`), and the readers are back to `main`.

## Bug 2 — lifecycle re-import wipes `.bin/<tool>` (pure pnpm; the substantive fix)

`runLifecycleHooksConcurrently` re-imports an injected workspace package into its targets after `prepare`/`postinstall`. The 2022 `scanDir`-into-`filesMap` workaround (#4299) fed target-internal paths to `importPackage`; once #11088 made `importIndexedDir`'s `makeEmptyDir` fast path the default, that path wipes the target's `node_modules` before copying, so the re-import dies with `ERR_PNPM_ENOENT` on `node_modules/.bin/<tool>`.

Fix: drop the `scanDir` workaround and pass `keepModulesDir: true` so `importIndexedDir` skips the destructive fast path and preserves the target's existing `node_modules` (bin symlinks + transitive deps) via its staging/move path. Stays on `storeController.importPackage`, so source files keep their **hardlinks** (no copy-loop regression). Net reduction vs `main`: the `scanDir` helper and the `node:fs` / `FilesMap` imports are removed.

## Tests

- The `deps-restorer` regression fixture `peer-variant-missing-resolution` **omits the base `packages:` entry**, so it encodes the actual pruned shape and reproduces the crash on `main`: reverting the `convertToLockfileObject` change yields `resolution: undefined` for the peer-variant (→ the `lockfileToDepGraph` crash); with this PR it is reconstructed as `{ type: 'directory', directory: … }`.
- A `lockfile.fs` unit test pins the heuristic boundary: a directory resolution is synthesized for a pruned `file:` peer-variant but **never** for a `file:` tarball.
- A `deps-installer` regression test covers the Bug 2 re-import (injected dep with a `prepare` script + a bin-having dependency).

## Validation

End-to-end on a real `injectWorkspacePackages` monorepo (`turbo prune --docker` → `pnpm install --frozen-lockfile`), on services that crash on **both** bugs with stock pnpm:

- pnpm with both fixes: the crashing services build.
- **vercel/turborepo#12825 + pnpm with only Bug 2** (Bug 1 fully reverted): the crashing services still **build** → confirms Bug 1 here is genuine defense-in-depth and turbo#12825 owns the root cause.
- Bug 2 reproduces on stock pnpm regardless of turbo (it is purely pnpm's importer fast-path).

Pairs with **vercel/turborepo#12825** (Bug 1 root cause; minimal repro **vercel/turborepo#12824**). Tracks #11663.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Eyalm321 <eyal@sunsationsusa.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: UApply Developer <developer@uapply.ai>
2026-05-20 00:31:07 +02:00
Minijus L
d1b340f3fe fix: synchronize default registry from pnpm-workspace.yaml for login/logout commands (#11744)
Closes #10099
2026-05-20 00:25:54 +02:00
shiminshen
56f3851904 feat(auth): implement pnpm login --scope (#11727)
The online documentation has long listed `pnpm login [--registry <url>] [--scope <scope>]`,
but the CLI only ever accepted `--registry`. `pnpm login --scope foo` errored with
`Unknown option: 'scope'` and there was no way to associate a scope with a registry
through the login command — users had to edit `.npmrc` by hand.

This change wires `--scope` through:
  - `cliOptionsTypes` / `rcOptionsTypes` declare the option.
  - `help()` documents it.
  - After the token is written, `@<scope>:registry=<registry>` is written into the same
    auth file (`~/.config/pnpm/auth.ini`), which `config.reader` already accepts as a
    source of `@scope:registry=` mappings.
  - Scope normalization: `--scope foo` and `--scope @foo` both produce `@foo`. Blank
    values (`""`, `" "`, `"@"`) are treated as unset rather than writing a broken
    `@:registry=` entry.

Ref: #11716

Co-authored-by: shiminshen <16914659+shiminshen@users.noreply.github.com>
2026-05-19 23:07:09 +02:00
Nicolas Beaussart
64afc9233e fix(publish): honor publishConfig access (#11746)
* fix(publish): honor publishConfig access

* test(publish): cover publishConfig access

* test(publish): handle invalid metadata json

* test(publish): replace verdaccio-dependent access tests with unit test

Verdaccio strips the top-level `access` field from publish payloads, so the
metadata-fetching integration tests could never pass. Replace them with a
direct unit test of the access-resolution logic in createPublishOptions.

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

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-19 22:49:42 +02:00
Zoltan Kochan
0a40f64b54 fix(pacquet/config): read global config.yaml and PNPM_CONFIG_* env vars (#11752)
* fix(pacquet/config): read global config.yaml and PNPM_CONFIG_* env vars

Pacquet's config loader only read `pnpm-workspace.yaml` (plus the
auth/network subset of `.npmrc`). Two sources pnpm v11 reads were
missing:

- `<configDir>/config.yaml` (e.g. `~/.config/pnpm/config.yaml`,
  `~/Library/Preferences/pnpm/config.yaml`,
  `%LOCALAPPDATA%/pnpm/config/config.yaml`). Now loaded between
  `.npmrc` and `pnpm-workspace.yaml`, with workspace-only keys
  (`nodeLinker`, `hoist`, `lockfile`, …) filtered out to mirror
  upstream's `isConfigFileKey` allowlist at `index.ts:299-309`.

- `PNPM_CONFIG_*` / `pnpm_config_*` env vars across the whole schema
  (pacquet previously only honored `NPM_CONFIG_WORKSPACE_DIR`). Now
  read for every `WorkspaceSettings` field and applied after
  `pnpm-workspace.yaml` so env vars win — matching upstream's
  `parseEnvVars` loop at `index.ts:471-488`.

Closes the scenario in pnpm/pnpm#11738: a user with
`enableGlobalVirtualStore: true` in `~/.config/pnpm/config.yaml`
running an install in a project whose `pnpm-workspace.yaml` doesn't
repeat the setting now lands in the global virtual store, same as
pnpm.

Note: the original issue proposed reading the full setting surface
from `.npmrc`, `~/.pnpmrc`, `~/.config/pnpm/rc`, and `npm_config_*`
env vars. Those proposals are anti-parity — pnpm v11 reads none of
them for non-auth/non-network settings (see `isNpmrcReadableKey` at
`localConfig.ts:197-198` and the `pnpm_config_*`-only prefix filter
in `env.ts:185-195`). Implementing them would diverge from pnpm.

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

* fix(pacquet/config): address PR review + perfectionist dylint

- Don't clobber a `virtualStoreDir` set in global `config.yaml` when a
  `pnpm-workspace.yaml` is present but doesn't redeclare the field.
  Guard the workspace-root re-anchor on `virtual_store_dir_explicit`.
  Adds a regression test (CodeRabbit review on #11752).

- Drop `parse_tri_array`'s `"null"` shortcut. Pnpm's `Array` schema
  feeds the env value through `JSON.parse` and then requires
  `Array.isArray`, so `PNPM_CONFIG_HOIST_PATTERN=null` is silently
  rejected upstream — accepting it here would diverge from pnpm.
  Test updated to assert rejection (Copilot review on #11752, but
  the suggestion direction had to be inverted for parity).

- Rename single-letter parameter / generic names (`s`, `T`, `p`) to
  satisfy perfectionist dylint's `single-letter-*` rules; add trailing
  comma to a multi-line macro invocation.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-19 22:49:22 +02:00
Zoltan Kochan
036b64e1c3 fix(pacquet/config): default publicHoistPattern to [] to match pnpm v11 (#11751)
Pacquet's default `public_hoist_pattern` was `["*eslint*", "*prettier*"]`,
but pnpm v11 defaults to `[]`. Any project that ran `pacquet install`
ended up with a `.modules.yaml` whose `publicHoistPattern` differed
from what pnpm computes on the next invocation, and the follow-up
`pnpm add` / `pnpm update` / bare `pnpm install` aborted with
`ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF` from `validateModules`.

Mirror pnpm v11's default exactly so `.modules.yaml` round-trips
cleanly across the two CLIs. Closes #11750.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-19 20:57:26 +02:00
Zoltan Kochan
b206a15395 feat(installing): delegate fetch / import / link to pacquet when configured (#11734)
When `configDependencies` declares pacquet (under either the unscoped `pacquet` or the scoped `@pnpm/pacquet` alias), pnpm delegates the fetch / import / link / build phases of an install to the pacquet Rust binary. Pnpm keeps owning dependency resolution — pacquet's resolver isn't ready yet — and hands pacquet a freshly-written lockfile to materialize.

Covered install shapes:

- frozen install (`tryFrozenInstall` → pacquet, no resolve needed)
- default isolated `nodeLinker` (`installInContext`: lockfileOnly resolve via JS, then pacquet)
- hoisted `nodeLinker` (same resolve-then-materialize shape)
- workspace partial install (subset of workspace projects mutated)
- agent-server install (`@pnpm/agent.client` resolves, pacquet materializes)

```yaml
# pnpm-workspace.yaml
configDependencies:
  "@pnpm/pacquet": "^0.2.0"     # or unscoped `pacquet`
```

## How it works

- `installing/commands/src/runPacquet.ts` resolves the platform binary via `createRequire(realpath(.pnpm-config/<name>/package.json))` — same algorithm the upstream wrapper uses, but skipping the JS shim's extra Node startup.
- Pacquet's NDJSON stderr is forwarded through `@pnpm/logger`'s global `streamParser` so `@pnpm/cli.default-reporter` renders its events the same way it renders pnpm's own. Non-JSON stderr lines pass through verbatim.
- A few pnpm-side log emits (`importing_done` placeholder, `pnpm:summary`) are suppressed when pacquet will take over so the reporter doesn't close streams or lock in empty diffs before pacquet's real events arrive. Pacquet's duplicate `pnpm:progress status:resolved` events are filtered on the resolve-then-materialize paths so the reporter doesn't double-count.
- `installing/deps-installer/src/install/index.ts` gates the delegation on a `runPacquet?: () => Promise<void>` callback in `StrictInstallOptions`. The CLI layer in `installing/commands/src/installDeps.ts` constructs the callback, threaded through both the single-project and workspace-recursive paths.
- The `pacquet` and `@pnpm/pacquet` npm packages ship the same JS shim from `pacquet/npm/pacquet/scripts/generate-packages.mjs`; per-platform binaries stay under the existing `@pacquet/<plat>-<arch>` scope and aren't duplicated.
2026-05-19 20:56:15 +02:00
Zoltan Kochan
1627943d2a feat(outdated): include node, deno, and bun runtimes (#11739)
`pnpm outdated` and `pnpm update --interactive` previously skipped runtime dependencies (`node`/`deno`/`bun` installed via the `runtime:` protocol). Both commands go through `outdatedDepsOfProjects` → `outdated()`, and the inner loop bailed out for anything `parseBareSpecifier` couldn't parse — which is everything `runtime:`-shaped. A runtime was only ever reported if the current install differed from the wanted lockfile entry, so the latest available version was never surfaced. The same gap silently affected `jsr:` and named-registry deps too.

Commits, smallest fix first → progressively cleaner architecture:

1. **`feat(outdated)`** — minimal fix: special-case runtime deps in `outdated.ts` so they appear in the table and the interactive update picker.
2. **`refactor(outdated)`** — per-resolver dispatch. Each protocol resolver gets its own "what's the latest?" function; `@pnpm/resolving.default-resolver` composes them.
3. **`refactor(outdated)`** — rename to `resolveLatest` (the function returns info regardless of whether the dep is outdated; "outdated" described a state, not an action).
4. **`refactor(outdated)`** — let the local-resolver own the `link:`/`file:` skip, drop the matching short-circuit in `outdated.ts`.
5. **`refactor(outdated)`** — slim `LatestQuery` / `LatestInfo` to the bare essentials; move `pickRegistryForPackage` into the npm-resolver where it belongs; derive `current`/`wanted` display from `pkgSnapshot.version` in `outdated.ts`.
6. **`chore(outdated)`** — drop stale tsconfig project reference left behind by #5.
7. **`refactor(outdated)`** — drop `wantedRef` from the query; resolvers detect protocol from `bareSpecifier` alone.

## Final architecture

`@pnpm/resolving.resolver-base` defines a single tiny protocol:

```ts
interface LatestQuery {
  wantedDependency: WantedDependency
  compatible?: boolean
}

interface LatestInfo {
  latestManifest?: PackageManifest
}

type ResolveLatestFunction = (query: LatestQuery, opts: ResolveOptions) =>
  Promise<LatestInfo | undefined>
```

- `undefined` from a resolver means "I don't claim this dep — try the next one."
- `{}` means "I claim it, but I can't tell you what's latest" (policy-blocked, network unavailable, or a protocol with no concept of latest — git/tarball).
- `{ latestManifest }` is the happy path.

Each protocol resolver (npm/jsr/named-registry, git, tarball, local, node/bun/deno runtimes) exports its own `resolveLatest*` function alongside its `resolve*`. `@pnpm/resolving.default-resolver` composes them into a single first-match dispatcher, surfaced through `@pnpm/installing.client` as `createResolver(...).resolveLatest`.

`outdated.ts` is protocol-agnostic: dispatches, then derives `current`/`wanted` display from `pkgSnapshot.version` (falling back to the raw ref for URL-shaped refs where the URL is the only diff signal between commits), uses raw `wantedRef !== currentRef` for the lockfile-shifted check, and pulls `packageName` from `dp.parse(relativeDepPath).name` so aliased deps still report under the real package name.

Per-resolver responsibilities:
- **npm-resolver** (`resolveLatestFromNpm` / `resolveLatestFromJsr` / `resolveLatestFromNamedRegistry`): match their respective spec shapes, call the matching `resolveFromX` with `'latest'` (or the original spec under `--compatible`), handle `MINIMUM_RELEASE_AGE_VIOLATION` and `ERR_PNPM_NO_MATCHING_VERSION` so policy-blocked deps don't surface as available updates. Picks the per-package registry internally via its ctx.
- **node/bun/deno runtime resolvers**: claim deps via `bareSpecifier.startsWith('runtime:')` + alias match, query their release sources for the latest version (only the version — no asset-hash fetches), return `{ latestManifest }`.
- **git / tarball resolvers**: claim deps via spec shape, return `{}` (no concept of "latest"); the caller still surfaces a ref-mismatch report if the lockfile shifted to a different commit/URL.
- **local-resolver**: returns `undefined` so `link:`/`file:`/`workspace:` deps fall through and get silently skipped.
2026-05-19 19:15:07 +02:00
Zoltan Kochan
9a8675d3f1 chore: update pnpm-lock.yaml (#11740)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-19 09:42:23 +02:00
Khải
f77244feb7 refactor(pacquet/config): replace default callback with self (#11736)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 08:34:17 +02:00
Khải
61e724cef3 ci(pacquet): pass --workspace --all-targets to clippy and check (#11735)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 08:24:07 +02:00
Khải
c83b002a7b refactor(pacquet): replace callback-DI with capability traits (#11730)
https://claude.ai/code/session_01Btd6rp2MgfXb3ArHwSJpqA

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 08:11:16 +07:00
Zoltan Kochan
c8d8fde6ca feat(config-deps): support optionalDependencies with platform filtering (#11725)
Extends `configDependencies` to resolve and install one level of `optionalDependencies`, with `os` / `cpu` / `libc` platform filtering applied at install time. Closes the prerequisite called out in #11723: this is what makes the esbuild/swc-style platform-binary pattern viable for config dependencies (e.g. shipping pacquet as a config dep with native binaries via `optionalDependencies`).

### What lands

- **Resolution** (`resolveOptionalSubdeps.ts`, wired into `resolveConfigDeps` and `resolveAndInstallConfigDeps`): after each top-level config dep resolves, walks one level of `optionalDependencies`, resolves each, and records them in the env lockfile with `os`/`cpu`/`libc` preserved. The parent's snapshot gets `optionalDependencies: { … }`. All variants are recorded regardless of host platform, so the env lockfile stays portable across machines.
- **Install** (`installConfigDeps.ts`): after the parent is installed into its GVS leaf, fetches each platform-compatible subdep into its own GVS leaf and creates a sibling symlink inside the parent leaf's `node_modules/`. Node's `realpath`-based resolution then makes `require('pkg-platform-arch')` from inside the parent resolve correctly. Stale siblings are pruned, so platform changes between runs produce a clean layout.
- **GVS hash** (new `calcGlobalVirtualStorePathWithSubdeps` in `graph-hasher`): the parent's GVS leaf hash now folds in the optional subdeps' full pkg ids. Without this, changing a subdep version while keeping the parent pinned would land in the same leaf and silently overwrite the sibling symlinks. The leaf function keeps its original "no children" contract; the new function is a separate entry point that pacquet can mirror cleanly.
- **Re-install detection**: the "skip if already installed" check compares the existing `.pnpm-config/{name}` symlink's `realpath` against the expected GVS leaf, not the package.json's name/version. With subdep versions now feeding the leaf hash, name/version alone isn't sufficient. The check only short-circuits the parent's re-import and re-symlink — `installOptionalSubdeps` always runs so platform-specific siblings get pruned and relinked when the host's effective platform changes (Rosetta x64 ↔ arm64, etc.).
- **Exact versions only**: subdep specifiers must be valid semver exact versions (e.g. `"1.2.3"`). Ranges (`"^1.0.0"`) and tags (`"latest"`) are rejected up-front with a `CONFIG_DEP_OPTIONAL_NOT_EXACT` error. With the parent pinned by integrity, the subdep's resolved version mustn't drift between machines.
- **Error handling**: optional-subdep resolution failures are logged via `skippedOptionalDependencyLogger` with `reason: 'resolution_failure'` (same shape as `installing/deps-resolver`) and the install continues — except for `ERR_PNPM_TRUST_DOWNGRADE`, which is a security signal that must still abort the install.

### Scope

Only one level deep. Transitive `dependencies` and lifecycle scripts remain unsupported — pacquet doesn't need them yet, and they carry meaningful security and complexity tradeoffs that deserve a separate discussion.

The env lockfile schema needs no changes: `LockfilePackageInfo` already carries `os`/`cpu`/`libc`, and `LockfilePackageSnapshot.optionalDependencies` already exists for recording the parent→child edge.

## Known limitation

If a workspace already had a resolved config dep in the env lockfile (`snapshots[pkgKey] = {}`) before this PR, optional subdeps won't be retroactively discovered on subsequent installs. Workaround: `pnpm update <pkg>` (or remove + re-add). In practice no published package today relies on `optionalDependencies` in a config dep — they couldn't, since the feature didn't exist — so the practical exposure is narrow. See the inline review thread for the design rationale.
2026-05-19 01:29:25 +02:00
Zoltan Kochan
cddf522cfa feat(pacquet): port lockfile verification (minimumReleaseAge + trustPolicy) (#11729)
Ports pnpm's lockfile-verification gate to pacquet, tracking #11722. The gate re-applies the resolver's policy checks (`minimumReleaseAge`, `trustPolicy='no-downgrade'`) to every entry in `pnpm-lock.yaml` immediately after the lockfile is loaded, before any resolution or fetch happens — so a lockfile resolved elsewhere (committed to the repo, restored from CI cache, or generated by a tool that bypassed local policy) can't reach the filesystem under a weaker policy.

Three new crates and four extended ones:

- **`pacquet-resolving-resolver-base`** — `ResolutionVerifier` trait + types.
- **`pacquet-resolving-npm-resolver`** — `NpmResolutionVerifier`, trust-rank logic, attestation fetcher, cached full-metadata fetcher, mirror helpers (path / IO).
- **`pacquet-lockfile-verification`** — fan-out runner, in-memory lockfile hasher, JSONL stat-and-skip cache + `recordLockfileVerified` wrapper.
- `pacquet-config` — new `cacheDir`, `minimumReleaseAge*`, `trustPolicy*` fields + `TrustPolicy` enum + `createPackageVersionPolicy`. Defaults match upstream byte-for-byte: `minimumReleaseAge` defaults to `1440` minutes (24 h) and `minimumReleaseAgeIgnoreMissingTime` defaults to `true`.
- `pacquet-reporter` — `pnpm:lockfile-verification` channel.
- `pacquet-registry` — enriches `Package` / `PackageVersion` with `time`, `_npmUser.trustedPublisher`, `dist.attestations`, `modified`.
- `pacquet-package-manager::install` — wires the gate between lockfile load and the frozen-lockfile dispatch.

Phase-by-phase landing order matches the plan in #11722. Ports against upstream pnpm at commit `2a9bd897bf`.
2026-05-18 23:32:12 +02:00
Zoltan Kochan
dfd8fbf709 chore: update pnpm-lock.yaml (#11559)
* chore: update pnpm-lock.yaml

* chore: update lockfile

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-18 21:28:57 +02:00
Zoltan Kochan
e01d2bc0a1 fix(pacquet/fs): fall back to absolute target across Windows drives (#11721)
* fix(pacquet/fs): fall back to absolute target across Windows drives

`pathdiff::diff_paths` does not return `None` when the two paths sit on
different Windows drive roots; it produces a path that climbs out with
`..` and then re-anchors with the target's drive letter (for example,
`..\..\C:\Users\...`). Windows rejects such a symlink target with
`ERROR_INVALID_PARAMETER` (os error 87), which is the failure mode CI
on Windows hits when the workspace lives on `D:` and the global store
installed by `setup-pnpm` lives on `C:`.

Node.js's `path.win32.relative` returns the absolute `to` argument
when the two paths share no common root, which is the behavior
upstream `symlink-dir` relies on. Mirror that by detecting unequal
`Component::Prefix` values up front and returning the absolute target
unchanged. Drive-letter comparison is case-insensitive to match
NTFS and Node.js semantics.

* fix(pacquet/fs): normalize verbatim Windows paths before relative diff

`Prefix::Disk('C')` and `Prefix::VerbatimDisk('C')` name the same
physical drive, but compare unequal as `Component::Prefix` values, so
the cross-root guard added in the previous commit would unnecessarily
fall back to an absolute target whenever one input was a verbatim
`\\?\C:\...` path. Worse, `pathdiff::diff_paths` itself would emit a
re-anchored garbage path on the variant-mismatched case if the guard
ever let it through.

Run both inputs through `dunce::simplified` on Windows, which strips
the `\\?\` prefix when the non-verbatim form is still valid, so
mixed verbatim and non-verbatim views of the same drive collapse
onto a common `Prefix::Disk` before the comparison and the diff.
Tighten `case_normalize` to map `VerbatimDisk` onto `Disk` for the
residual long-path case that `dunce` declines to simplify.

* style(pacquet/fs): split relative_target_inner per platform

`clippy::needless_return` rejects the explicit `return` in the
Windows `{ ... }` block because the block itself is the function's
tail expression on Windows. The block-with-cfg-attribute layout
also obscured that the function body shrinks to a single tail
expression on non-Windows. Extracting two `#[cfg]`-gated
`relative_target_inner` helpers makes both targets read as a
straight tail expression and drops the clippy violation.

* fix(pacquet/fs): make same_path_root variant-strict

Collapsing `Disk` and `VerbatimDisk` (and the UNC analogues) onto a
common variant in `same_path_root` is unsafe when one input is a
verbatim path that `dunce::simplified` declined to strip (e.g., a
path long enough that the non-verbatim form would exceed the legacy
limit). `same_path_root` would declare the two paths same-root, but
`pathdiff::diff_paths` would still walk `Component::Prefix` for
equality and emit a re-anchored garbage path on the variant
mismatch — the same `..\\..\\C:\\Users\\...` shape that triggers
`ERROR_INVALID_PARAMETER` (os error 87) on Windows.

Restore the variant-strict comparison so mismatched variants fall
back to an absolute target, which is the correct outcome on the
edge case. The common short-path mixed-form case (`\\?\C:\foo`
paired with `C:\bar`) still produces a relative target because
`dunce::simplified` normalizes the variants upstream of this check.

* docs(pacquet): tests are documentation; do not narrate them in code

Add an explicit rule to AGENTS.md: when a behavioral scenario or
edge case is already captured by a test (name, setup, assertions),
do not re-narrate it in a doc comment on the implementation. The
doc comment should state the contract; the test demonstrates the
behavior.

Apply the rule to the recently added Windows symlink fix. The doc
comments on `relative_target_for` and `same_path_root` had grown
into mini essays restating the `ERROR_INVALID_PARAMETER` failure
mode, the CI scenario, and the `dunce::simplified` rationale,
all of which is covered by the three Windows tests. Trim both to
the contract plus one short note on the only non-obvious invariant
(why `same_path_root` is variant-strict). The tests in turn shed
their multi-paragraph preambles in favor of self-explanatory names
and direct assertions, keeping only a one-line regression pointer
on the CI-failure test.

* docs(pacquet/fs): describe same_path_root match shape accurately

The previous doc claimed "same UNC share" as a match condition, but
the implementation requires identical `Prefix` after `dunce::simplified`
with only drive-letter case folding. UNC server/share differences in
case or `UNC`/`VerbatimUNC` variant fall back to absolute.
2026-05-18 18:54:14 +02:00
Khải
bfa861fe04 refactor(pacquet/config): replace env mutations with DI pattern (#11718)
* refactor(pacquet/config): thread default_store_dir through the EnvVar DI seam

Replaces process-environment mutation in the `default_store_dir` tests with
the dependency-injection pattern established in pnpm/pacquet#339 and
consolidated for the in-tree pacquet subtree in #11708. Tracks
pnpm/pacquet#343 — the original issue still applied after pacquet was
merged into this repo because the env-mutating tests came with it.

The four affected unit tests (`test_default_store_dir_with_pnpm_home_env`
and `test_default_store_dir_with_xdg_env` in `defaults::tests`, plus
`should_use_pnpm_home_env_var` and `should_use_xdg_data_home_env_var` in
`lib::tests`) now drive each branch with per-test unit structs that
satisfy `EnvVar`. No `EnvGuard` snapshot, no `unsafe` block, no
process-environment write. The `home_dir` and `current_dir` closures call
`unreachable!` for branches the early `PNPM_HOME` / `XDG_DATA_HOME`
returns short-circuit before consulting them, documenting the
precondition the way the style guide's worked example does.

`default_store_dir` is now generic over `Sys: EnvVar` and takes the
`home_dir` and `current_dir` lookups as `FnOnce` closures, mirroring the
shape of `Config::current`. A thin args-less wrapper
`default_store_dir_host` wires the production `Host` provider together
with `home::home_dir` and `env::current_dir` so the SmartDefault
expression on `Config::store_dir` stays short. The `have_default_values`
wiring assertion now compares against
`default_store_dir::<Host>(home::home_dir, env::current_dir)` instead of
the old args-less helper.

`npmrc_auth::tests::ignores_non_auth_keys` no longer needs to hold the
EnvGuard global lock against the two `Config::new()` snapshots it
compares — nothing in the crate mutates `PNPM_HOME` or `XDG_DATA_HOME`
anymore, so the env-derived `store_dir` is observed identically by both
calls even under nextest's in-process parallelism.

`EnvGuard` itself stays in `pacquet-testing-utils` because the proxy
cascade and `NPM_CONFIG_WORKSPACE_DIR` tests in `lib::tests`, as well as
out-of-crate users in `git-fetcher` and `executor`, still rely on it.
Retiring it entirely is out of scope for this issue.

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

* fix(pacquet/config): add Debug bound on default_store_dir's Error parameter

The Windows branch of `default_store_dir` calls
`current_dir().expect("current directory is unavailable")`, which
requires `Error: Debug` on the `Result<PathBuf, Error>` returned by the
`CurrentDir` closure. The original `where` clause didn't declare that
bound, so the function compiled on Linux/macOS (where the Windows
branch is `#[cfg(windows)]`-gated out) but failed on
`Lint and Test (windows-latest)` once the cfg fired.

All current callers pass `env::current_dir` (Error = `io::Error`) or
pin `Error = std::io::Error` via turbofish on test fakes — both
already satisfy `Debug` — so this is a pure type-bound fix with no
behaviour or call-site change.

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

* docs(pacquet): add shared-process-state DI exception to the style guide

The "Dependency injection for tests" gating list in
`pacquet/CODE_STYLE_GUIDE.md` enumerated four reasons to reach past
real fixtures for the DI seam: filesystem error kinds, deterministic
time, external-service happy paths, and unreachable-by-design
preconditions. It did not cover the case this PR retired from
`default_store_dir`: tests that mutate a single per-process slot
(env vars, cwd, umask, signal handlers, …) and so race with every
other test in the same process.

The pre-existing `EnvGuard` workaround restored correctness only by
holding a binary-wide mutex around `unsafe { env::set_var(...) }`,
which forecloses parallelism inside the affected tests and leaves
`unsafe` in the test source. A capability-trait fake keeps the read
deterministic and the mutation contained to the test that needs it.

Adds that bullet to the style guide between "Deterministic time" and
"External-service happy paths," and updates rule 7 in
`pacquet/AGENTS.md` (and its `CLAUDE.md` / `GEMINI.md` symlink
targets) so the summary mirrors the longer list.

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

* docs(pacquet): drop unfounded set_current_dir example from DI guidance

The previous commit cited `set_current_dir` and "the umask" as
illustrative examples in both the style guide and the AGENTS rule 7
summary. Neither is actually used anywhere in the pacquet codebase
today — only `env::set_var` is. Speculative examples in a style
guide invite contributors to chase patterns that don't exist, so
narrow the wording to the one slot we have a concrete example for
while keeping the principle generalisable (the bullet still says
"any future analogue").

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

* docs(pacquet): revert example narrowing on the shared-process-state bullet

Reviewer feedback: keep the broader `set_current_dir` / umask /
signal-handler / global-allocator examples in the DI gating bullet
and rule 7 summary. They're not used in the codebase today, but the
analogues are useful as forward-looking illustrations so the next
contributor recognises the same shape when it comes up.

Restores the wording introduced in 96040d7; reverses the narrowing in
d930bed.

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

* docs(pacquet): document when library code may read process state

Adds a "Reading process state" section to CODE_STYLE_GUIDE.md, placed
right before "Dependency injection for tests" so the reading order
captures the two questions in dependency order: first ask whether you
should be reaching for `env::var` / `env::current_dir` at all, then
ask how to inject it if the answer is yes.

The section pins:

1. Library code should rarely call `std::env::var`, `std::env::var_os`,
   or `std::env::current_dir` directly. The default fix is a parameter
   (`&Path`, `&str`, an `Option<String>`, or a `FnOnce` closure) so the
   caller decides which value to read; `Config::current` and
   `workspace::find_workspace_dir_from_env_with` already follow this.
2. When the read genuinely needs to happen, route through the DI seam
   (`Sys: EnvVar`, `Sys::var(name)`).
3. The narrow legitimate direct-call case is computing a `Config`
   default that has no caller — `default_store_dir`,
   `default_modules_dir`, `default_virtual_store_dir`. This is the
   "begrudging" use that pnpm/pacquet#343 + pnpm/pnpm#11718 finally
   threaded through the DI seam.
4. Other accepted boundary reads are program-entry knobs
   (`RAYON_NUM_THREADS`, `TRACE`) and the lifecycle-script env snapshot
   in `crates/executor` / `crates/git-fetcher` that forwards the parent
   env to spawned children verbatim.

The section belongs in CODE_STYLE_GUIDE.md (code-level convention),
not CONTRIBUTING.md (PR workflow) or AGENTS.md (agent-specific
operating rules); rule 7 in AGENTS.md already cross-links the style
guide, so no change there.

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

* refactor(pacquet/config): inline default_store_dir's SmartDefault wiring

Reviewer pointed out that the args-less `default_store_dir_host`
wrapper could be inlined into the `#[default(_code = ...)]`
expression on `Config::store_dir` — the SmartDefault macro accepts
the turbofish form `default_store_dir::<Host, _, _, _>(home::home_dir,
env::current_dir)` verbatim, and once the wrapper is gone there's
nothing for the indirection to earn.

Dropping the wrapper also removes a small motivation for the
`Host` import inside `defaults.rs` — adjust the doc comment on
`default_store_dir` to cite `crate::Host` by full path instead.

The `#[cfg(windows)] Error: std::fmt::Debug` bound that the same
reviewer asked about is not feasible: attributes on where-clause
predicates are still unstable (rust-lang/rust#115590), so the bound
stays unconditional with a comment explaining why.

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

* docs(pacquet/config): purge stale default_store_dir_host references

Commit 49c690c inlined the SmartDefault expression for
`Config::store_dir` and dropped the `default_store_dir_host`
wrapper, but three comments still pointed at the wrapper or at the
old single-type-parameter signature of `default_store_dir`.
CodeRabbit and Copilot flagged all three on review:

- `crates/config/src/lib.rs`, `have_default_values`: the comment said
  the SmartDefault resolved "via the thin `default_store_dir_host`
  wrapper". Now reflects the direct
  `default_store_dir::<Host, _, _, _>(home::home_dir,
  env::current_dir)` call.
- `crates/config/src/npmrc_auth/tests.rs`, `ignores_non_auth_keys`:
  the comment cited `default_store_dir::<Host>` with no closure
  parameters. Now spells out the four-parameter turbofish that
  matches the current signature.
- `CODE_STYLE_GUIDE.md`, "Reading process state": the paragraph
  recommended an args-less `default_store_dir_host` wrapper as the
  pattern to follow. Now recommends inlining the production
  composition at the `SmartDefault` site directly.

Copilot also queried whether `pnpm/pnpm#11718` was the right number
in the DI section's `EnvGuard` retirement citation — confirmed it
is. #11708 introduced the DI seam consolidation; this PR is the
one that retires the env-mutation pattern from `default_store_dir`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 17:23:58 +02:00
Zoltan Kochan
471b59025d chore: update pnpm to v11.1.3 (#11719)
* chore: update pnpm to v11.1.3

* fix: lock pacquet version
2026-05-18 17:23:32 +02:00
Zoltan Kochan
cd80b2c8ae chore(release): 11.1.3 (#11717) v11.1.3 2026-05-18 15:42:32 +02:00
Zoltan Kochan
2a9bd897bf perf: record locally-resolved lockfile in verification cache (#11714)
The lockfile verification cache currently only records the lockfile that exists at the **start** of an install. So a flow like:

```
pnpm install <pkg>
rm -rf node_modules
pnpm install
```

re-runs the per-package registry round-trip against the newly written lockfile, even though the local resolver already enforced the policy when picking those versions. The fresh lockfile is now recorded immediately after each install-time write, so the second install takes the cache fast path.

## Implementation

### Recording the post-resolution lockfile

- New helper `recordLockfileVerified` (in `installing/deps-installer/src/install/`). Gated on `cacheDir` + non-empty `resolutionVerifiers` — same gate the pre-resolution verifier uses.
- Two thin combiners over the lockfile writers: `writeWantedLockfileAndRecordVerified` and `writeLockfilesAndRecordVerified`. The install paths use these so the record always runs alongside the write.

### Hash stability: writer returns the canonical lockfile

The cache stores `hashObject(LockfileObject)` and the next install computes the same hash off the file it loads from disk. For the hashes to match, both ends must compute over structurally identical objects. They don't, naïvely: the in-memory write object can carry `undefined` optional fields (e.g. `settings.dedupePeers = undefined` from `opts.dedupePeers || undefined` in install code) that YAML drops on serialize — `object-hash` treats undefined vs missing as distinct values.

- `writeWantedLockfile` / `writeLockfiles` (in `@pnpm/lockfile.fs`) now return the canonical post-write `LockfileObject`: `convertToLockfileObject(stripUndefinedDeep(lockfileFile))`. The strip walks the existing object graph in memory rather than going through a `yaml.load` round-trip, so non-cache callers (deploy, deps-restorer, make-dedicated-lockfile, agent server) pay near-zero cost.
- Install hooks hash the writer's returned value, not the raw in-memory input. Guaranteed by construction to match what the next reader produces.

### `useGitBranchLockfile` correctness

The pre-resolution verification gate and the new post-write recorder were both keying cache records on a hard-coded `pnpm-lock.yaml`. Under `useGitBranchLockfile` the actual file is `pnpm-lock.<branch>.yaml`, so the stat shortcut hit `ENOENT` and the cache effectively never engaged for git-branch users. Both sites now resolve the real filename via `getWantedLockfileName`. The wrappers compute it once and pass it to the writer via a new optional `lockfileName` opt so `useGitBranchLockfile` installs don't fork `getCurrentBranch` twice per write.

### Bug fix unrelated to the cache, found during review

`writeLockfiles`' differs branch was deciding whether to remove or keep `node_modules/.pnpm/lock.yaml` based on `isEmptyLockfile(wantedLockfile)`. Filtered-current callers (deps-restorer) pass an empty current against a non-empty wanted, so this could leave a stale current lockfile on disk. Fixed to key off the current.

### Comments policy

`AGENTS.md` (and `pacquet/AGENTS.md`) now spell out the comment defaults: write self-documenting code, do not restate at call sites what the callee's JSDoc / doc comment already says, comments are reserved for the non-obvious *why*. The pruning pass in this PR brings the changed code in line.

## API surface

- `@pnpm/lockfile.fs` (minor):
  - `writeWantedLockfile`: return widened from `Promise<void>` to `Promise<LockfileObject>`. New optional `lockfileName` opt.
  - `writeCurrentLockfile`: return widened to `Promise<LockfileObject | undefined>` (undefined when the empty-lockfile branch unlinks).
  - `writeLockfiles`: return widened from `Promise<void>` to `Promise<{ wantedLockfile, currentLockfile }>`. New optional `wantedLockfileName` opt. New exported `WriteLockfilesResult` type.
  - New export: `getWantedLockfileName`.
- `@pnpm/installing.deps-installer` (patch): internal-only wrappers; no external API change.
2026-05-18 14:55:16 +02:00
Khải
e2a3c68309 chore(pacquet/dylint): upgrade perfectionist to 0.0.0-rc.15 (#11709)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 11:25:35 +00:00
Zoltan Kochan
4a79336473 feat: report lockfile verification progress (#11712)
* feat: report lockfile verification progress

The lockfile resolution verifier introduced in #11705 runs an unbounded
registry round-trip on cache miss and was previously silent — on a cold
registry cache users saw nothing for several seconds. Emit pnpm:lockfile-verification
log events (started/done) around the actual verification pass and render
them in the default reporter as a transient progress line that collapses
into a final "verified" summary with entry count and elapsed time. The
cached short-circuit stays silent.

* feat: include lockfile path in verification log and render when non-standard

Add `lockfilePath` to the `pnpm:lockfile-verification` event payload so
consumers always know which lockfile a `started`/`done` pair refers to.
In the default reporter, render the path in the message only when the
lockfile lives outside the workspace root (or, for non-workspace
installs, outside cwd) — the common case stays uncluttered, while
custom `lockfileDir` setups now surface in the verification line.

* feat: name what the lockfile verification actually checks in the rendered message

"Verifying lockfile" was opaque about *what* was being verified. Reword
the rendered messages to explicitly name the check ("supply-chain
policies"), so users on a cold-cache pause understand what's happening
instead of just seeing the pause.

* fix: skip lockfile verification emission for empty candidate set

A non-empty lockfile.packages whose snapshots all fail name/version
extraction would still emit a "Verifying lockfile (0 entries)" line even
though no verifier work runs. Bail before emission when the candidate
map is empty so the no-op branch stays silent, matching the contract
for the other no-op branches (empty verifiers, no lockfile.packages).

* fix(reporter): always close out the verifying-lockfile frame

Address two Copilot review points on #11712:

1. The verifier emitted `started` but no terminal event when violations
   were found or when the registry fan-out threw, leaving "Verifying
   lockfile…" as the last frame for that block in ansi-diff mode (and
   an unmatched line in CI logs). Add a `failed` status to the logger,
   wrap the fan-out in try/finally so a terminal event is emitted on
   every exit path that emitted `started`, and render a brief failure
   line so the spinner-style frame is replaced before the PnpmError
   block prints.

2. The path-suppression heuristic used strict `===` between
   path.dirname(lockfilePath) and expectedDir, which broke on trailing
   separators and slash-direction differences. Switch to a
   path.relative-based check so a workspaceDir like `/repo/` or a
   Windows path with mixed slashes still correctly suppresses the
   redundant "at <path>" suffix.

* docs: update lockfile verification logging behavior

The lockfile verifier now emits log events during the registry round-trip pass, improving user visibility into the process.
2026-05-18 11:38:47 +02:00
Khải
a671dda173 test(pacquet): do not tolerate the lacking of tools (#11713)
* test(git-fetcher): drop skip_if_no_{git,npm} guards

Replace runtime probe-and-skip helpers with the existing `.unwrap()`
calls — if `git`, `node`, or `npm` is missing the test will fail
loudly rather than silently return. Tolerance defeats the purpose of
testing: an under-provisioned environment is a bug in the environment,
not something the suite should paper over. Git is ubiquitous and
Node.js is a documented prerequisite for building pnpm.

Codify the rule in pacquet/AGENTS.md so future ports don't reintroduce
the pattern. Platform-locked tools are still allowed to gate via
`#[cfg(...)]` or `#[cfg_attr(..., ignore = "...")]`, which surface in
the test report instead of disappearing.

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

* docs(test-porting): drop stale skip_if_no_npm note

The TEST_PORTING.md entry for fetcher_runs_prepare_script_when_allowed
still advertised the deleted `skip_if_no_npm` helper as the recommended
pattern. Reword it to match the new AGENTS.md rule: rely on the existing
`.unwrap()` calls and let missing tools fail the test loudly.

Spotted by Copilot review on #11713.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 11:01:16 +02:00
Khải
e74a6b7e48 test(pacquet): coverage (#11710)
* test: fill coverage holes across lockfile, package-manifest, fs, and friends

Adds ~44 unit and integration tests to close the easier targets in the
coverage analysis at #339-style boundaries. Touches:

- `lockfile::resolved_dependency`: cover `as_alias` / `ver_peer` for
  non-matching variants, alias / parse error variants, `TryFrom<Cow>`,
  serialize-alias / serialize-link, `From<PkgVerPeer> / From<PkgNameVerPeer>`,
  and a Display vs. `String` round-trip.
- `lockfile::freshness`: cover the plural arms of `SpecDiff::Display`
  and the comma separator inside the removed/modified loops.
- `lockfile::pkg_name`: cover `TryFrom<String>` and `TryFrom<Cow>` happy
  and empty-input paths.
- `lockfile::snapshot_dep_ref`: cover `ver_peer` and `From<PkgVerPeer>`.
- `lockfile::save_lockfile`: cover the `CreateDir` / `RemoveFile` /
  `RenameFile` error classifications by planting a regular file or
  directory where the writer expects a file or a writable path.
- `registry::package`: cover `PartialEq`, `latest`, and `pinned_version`
  happy and no-match paths.
- `graph-hasher::engine_name`: cover `detect_node_version` /
  `detect_node_major` when `node` is on PATH; skip cleanly otherwise.
- `graph-hasher::object_hasher`: cover the null / bool / array arms of
  the bytestream serializer.
- `patching::apply`: cover non-NotFound read-patch error, partial-delete
  non-empty result, unsupported rename/copy operation, and `Create`
  with an unwritable parent.
- `workspace-state`: cover the `CreateDir` / `ReadFile` / `ParseJson`
  error variants.
- `package-manifest`: cover `from_path` ENOENT, `add_dependency` on a
  non-object field, `safe_read_package_json_from_dir` non-NotFound IO
  error, and `convert_engines_runtime_to_dependencies` for unsupported
  shapes.
- `fs::file_mode`: cover `EXEC_MASK` / `EXEC_MODE` constants,
  `is_executable` for all positions, and `make_file_executable` on Unix.
- `executor`: cover `execute_shell` happy and non-zero-exit paths.
- `cli/tests/run.rs`: new integration tests for `pacquet run`,
  including argument forwarding and `--if-present`.

Also bumps `hex_decode` in `graph-hasher::tests` to use
`is_multiple_of` so test-time clippy stays clean under Rust 1.95.

https://claude.ai/code/session_01D8WBTfQzTpsZsRknrzwNKL

* style(package-manifest): drop trailing comma in single-line `assert!`

Dylint's perfectionist::macro-trailing-comma rule rejected the single-
line `assert!` formatting I'd accidentally inherited from the multi-
line shape — remove the trailing comma so the lint clears.

https://claude.ai/code/session_01D8WBTfQzTpsZsRknrzwNKL

* test(graph-hasher): make `node` a hard prerequisite for detect tests

The two `detect_node_*` tests previously skipped silently when `node`
was missing from `PATH`. `node` is a documented prerequisite of the
test suite (see `pacquet/CONTRIBUTING.md`'s setup section), so a
missing binary is a test-env bug to surface, not a condition to paper
over. Switch the `let Some(...) = ... else { return }` skips to
`expect` so a missing `node` fails loudly with a clear message.

https://claude.ai/code/session_01D8WBTfQzTpsZsRknrzwNKL

* test(cli,patching): switch fixtures to `json!` and `text_block_fnl!`

Addresses six review comments on #11710:

- `cli/tests/run.rs`: the three `package.json` fixtures used
  `format!("{{ ... }}", marker = …)` with literal-brace escapes,
  which is awkward and brittle when path strings contain
  JSON-special characters. Switched to
  `json!({...}).to_string()` so serde handles escaping and the
  shape reads as JSON, not as a brace-escaped template.
- `patching/src/apply/tests.rs`: the three patch fixtures I
  added used `"\<newline>...\n"` raw strings. Converted them to
  `text_block_fnl!`, matching the convention the rest of the
  workspace (lockfile, package-manager, testing-utils) uses for
  multi-line fixture text. The `_fnl` variant keeps the trailing
  newline that git-diff parsers expect.

Pre-existing fixtures in the same file (`IS_POSITIVE_PATCH`,
etc.) were not touched — keeping the scope to the lines
reviewers flagged.

https://claude.ai/code/session_01D8WBTfQzTpsZsRknrzwNKL

* style(registry): use `assert_ne!` over `assert!(lhs != rhs)`

Idiomatic Rust and produces a better failure message when the
assertion trips. Caught during a self-review pass over #11710.

https://claude.ai/code/session_01D8WBTfQzTpsZsRknrzwNKL

* test: address zkochan + Copilot review feedback on #11710

- Move executor + fs::file_mode unit tests from inline modules
  to the project's standard `src/<parent>/tests.rs` layout. The
  convention is documented in `CODE_STYLE_GUIDE.md` under "Unit
  test file layout"; zkochan flagged the violation in the
  executor file.

- Gate `pacquet-executor::tests` on `cfg(all(test, unix))` at
  the declaration site so the `use super::execute_shell` import
  isn't dead on Windows. Copilot flagged that
  `clippy --tests --deny warnings` would fail there.

- Drop `execute_shell_propagates_nonzero_exit_as_ok`. Copilot
  flagged that it regression-pinned a behavior that *should*
  change: pnpm/npm `run` exits with the script's status, but
  pacquet's wrapper currently returns `Ok(())` regardless. A
  fix belongs in its own PR; until then we should not add tests
  that block that fix.

- Quote the temp-path redirect targets in the `cli/tests/run.rs`
  shell fixtures so a tempdir path containing a space
  (`/var/folders/...` on macOS) doesn't split the `touch` /
  redirect argument. Copilot flagged the unquoted paths.

The `fs::file_mode::make_file_executable_sets_exec_bits` test
keeps `#[cfg(unix)]` at the test site (and moves its
`make_file_executable` import inside) rather than gating the
whole `tests` module — the other two tests
(`exec_constants_pin_pnpm_layout`,
`is_executable_matches_any_exec_bit`) are platform-neutral and
should still run on Windows.

Copilot's concern about `detect_node_version`'s `expect` (in
`engine_name.rs`) is intentionally not addressed: KSXGitHub
asked for the hard-fail in 5d0987c on grounds that `node` is a
documented prerequisite of the test suite, and the human
reviewer's call wins over the bot's.

https://claude.ai/code/session_01D8WBTfQzTpsZsRknrzwNKL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 10:21:42 +02:00
Zoltan Kochan
4195766f10 feat: tighten minimumReleaseAge — auto-exclude, lockfile verification, and interactive prompt (#11705)
Three coordinated changes that close the silent-bypass gap in loose `minimumReleaseAge` mode AND the discover-by-loop UX problem in strict mode (#10488), plus a parallel hardening of the lockfile verifier:

1. **Auto-collect into `minimumReleaseAgeExclude` (loose mode)** — fresh resolutions that fall back to a version newer than the cutoff are auto-recorded into the workspace manifest's `minimumReleaseAgeExclude`. A single info message lists what was persisted. The workspace manifest writer dedupes against existing entries.

2. **Lockfile verifier runs in loose mode too** — `createNpmResolutionVerifier` no longer gates on `minimumReleaseAgeStrict`. With auto-collect keeping the exclude list explicit, every accepted-immature pin must be on the list — same contract strict mode enforces. Lockfiles produced under a weaker (or absent) policy that still hold immature entries are rejected the same way strict mode would.

3. **Strict mode prompts on the aggregate set instead of throwing on the first** — the resolver always collects every immature direct and transitive in one pass; the install command's `handleResolutionPolicyViolations` checkpoint decides what to do with the set. Interactive (TTY) prompts the user once with the full list (default = No) and asks whether to add them all to `minimumReleaseAgeExclude` and proceed. Approve → install continues, persisted at the end. Decline → resolution aborts before the lockfile, package.json, or modules dir is touched. Non-interactive (CI) keeps `ERR_PNPM_NO_MATURE_MATCHING_VERSION` as the exit code but lists every offending entry instead of just the first one the resolver happened to hit.

4. **The lockfile verifier now also covers `trustPolicy: 'no-downgrade'`.** The same post-resolution gate that re-checks `minimumReleaseAge` on lockfile entries now re-runs `failIfTrustDowngraded` for every npm-registry entry whose name isn't on `trustPolicyExclude`. The two checks share a single full-metadata fetch per package, so the extra coverage doesn't cost an extra round trip when both policies are active. Resolver-time trust checks still run as before — this just closes the gap when an entry bypasses resolution (peek path, `--frozen-lockfile`, restored CI cache).

The steady-state flows:

- **Loose mode, `pnpm add foo@immature`**: lockfile clean, verifier no-op, resolver picks via lowest-version fallback, `foo@immature` lands in `minimumReleaseAgeExclude`, install succeeds. Subsequent `pnpm install --frozen-lockfile` in CI verifies against the populated list and succeeds.
- **Strict mode (interactive), security bump to `next@15.5.9`**: resolver collects `next@15.5.9` AND every immature `@next/swc-*@15.5.9` shim. pnpm prompts once with the full list. User approves → install completes, all entries persisted in `pnpm-workspace.yaml`. CI then runs the populated config cleanly.
- **Strict mode (non-interactive / CI)**: aborts with `ERR_PNPM_NO_MATURE_MATCHING_VERSION` listing every immature entry's `name@version` and publish time — no more discover-by-loop dance.
- **Teammate commits a poisoned lockfile**: single-policy batches reject with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` (or `ERR_PNPM_TRUST_DOWNGRADE`); a batch that trips both policies escalates to the generic `ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION` and lists each entry's per-policy code in the breakdown.

### Implementation

- The npm resolver always falls back to the lowest matching version when no mature version satisfies the range, and flags the result with `ResolveResult.policyViolation` instead of throwing `NO_MATURE_MATCHING_VERSION`. `deferImmatureDecision` and `strictPublishedByCheck` are gone — every caller (install, dlx, outdated, self-update) inspects the violation and decides what to do.
- `policyViolation` flows from `ResolveResult` → `PackageResponse.body.policyViolation` → a shared accumulator in `ResolutionContext` → the `resolutionPolicyViolations` field on `resolveDependencyTree`'s return → out through `mutateModules` / `addDependenciesToPackage` to the install command.
- The violation type lives in `@pnpm/resolving.resolver-base` as `ResolutionPolicyViolation`; the npm resolver exports the two built-in codes (`MINIMUM_RELEASE_AGE_VIOLATION_CODE`, `TRUST_DOWNGRADE_VIOLATION_CODE`) as constants so consumers reference one source of truth.
- `handleResolutionPolicyViolations` runs between `resolveDependencyTree` and `resolvePeers` — the resolver-agnostic checkpoint where the install command's plan prompts (TTY) or aborts (no-TTY) with the full violation list.
- `setupPolicyHandlers` (in `installing/commands/src/policyHandlers.ts`) composes per-policy handlers behind a uniform plan interface: each handler has its own `handleResolutionPolicyViolations` (filter by code, decide what to do) and `pickManifestUpdates` (return a typed `WorkspaceManifestPolicyUpdates` patch the install command spreads into `updateWorkspaceManifest`). Today the only registered handler is `createMinimumReleaseAgeHandler` — strict + TTY prompts via `enquirer`, strict no-TTY throws `ERR_PNPM_NO_MATURE_MATCHING_VERSION` with every entry listed, loose mode auto-persists at the tail. Strict + `--no-save` is rejected up-front via `ERR_PNPM_STRICT_MIN_RELEASE_AGE_REQUIRES_SAVE`. Future policies plug in via a sibling factory + push into the handlers list, with no changes to `installDeps.ts` / `recursive.ts`.
- `installDeps` / `recursive` drain `pickManifestUpdates` after install and spread the patch into `updateWorkspaceManifest`. Plain `pnpm install` (no `--update`, no params) now still updates the workspace manifest when any handler contributes a patch. The `install` command's CLI schema gained `save: Boolean` so `--no-save` actually flows through to `opts.save = false` instead of being silently dropped by nopt.
- `makeResolutionStrict` (in `installing/client`) wraps a `ResolveFunction` and rethrows any `policyViolation` as a `PnpmError`. Used by `dlx` and `self-update` under strict `minimumReleaseAge` OR `trustPolicy: 'no-downgrade'`, since one-shot callers have nowhere to defer a violation to. Violation-code → error-code mapping lives in one place so future violation kinds get consistent UX.
- `createNpmResolutionVerifier` extends its check to `trustPolicy: 'no-downgrade'` — same per-entry fan-out, same cache key, sharing the full-metadata fetch with the maturity check. Trust-fetch errors now propagate up so the violation reason carries the underlying message (network code, 404 detail) instead of a generic "metadata is unavailable".
- `verifyLockfileResolutions`'s aggregate throw uses the per-policy code when every violation in the batch shares it, and escalates to a generic `LOCKFILE_RESOLUTION_VERIFICATION` (with per-entry codes in the breakdown) for mixed batches.
- The pnpm agent path refuses installs under `trustPolicy: 'no-downgrade'` (`ERR_PNPM_TRUST_POLICY_INCOMPATIBLE_WITH_AGENT`) — the agent has no server-side counterpart to that check yet, so silently allowing it would land a lockfile the local verifier would later reject. `minimumReleaseAge` is forwarded to the agent and enforced server-side, so that combination is fine.

### Pacquet parity

Pacquet only carries a stub reference to `minimumReleaseAgeExclude` (see `pacquet/crates/package-manager/src/version_policy.rs`); the broader `minimumReleaseAge` and `trustPolicy` policies aren't ported yet, so this feature is outside pacquet's current surface area. It'll come along when pacquet ports the policies.

### Closes

- Closes #10488 (resolves the discover-by-loop dance for security bumps without needing `withTransitives`).
2026-05-18 09:51:11 +02:00
Khải
02f8138f13 refactor(pacquet): optional DI pattern + documentation (#11708)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 06:08:23 +00:00