Commit Graph

488 Commits

Author SHA1 Message Date
Zoltan Kochan
cb6f9e58c3 feat(lockfile): select_platform_variant + PlatformSelector (#437 slice B) (#466)
* feat(lockfile): select_platform_variant + PlatformSelector (#437 slice B)

Add the variant-picking logic for `VariationsResolution` ahead of
Slice D's install-pipeline dispatch:

- `PlatformSelector { os, cpu, libc: Option<String> }` mirrors
  upstream's [`PlatformSelector`](https://github.com/pnpm/pnpm/blob/94240bc046/resolving/resolver-base/src/index.ts#L78-L83).
  The `libc` tri-state encodes pnpm's `string | null | undefined`
  shape: `None` (host doesn't care about libc — macOS/Windows/BSD)
  and `Some("glibc")` collapse to the same matching arm (variant
  must have no `libc:` annotation); `Some("musl")` requires an exact
  `libc: "musl"` annotation so the glibc default doesn't silently
  win on a musl host.

- `select_platform_variant(variants, selector)` ports
  [`selectPlatformVariant`](https://github.com/pnpm/pnpm/blob/94240bc046/resolving/resolver-base/src/index.ts#L92-L98).
  Iterates `variants` in declaration order and returns the first
  variant whose `targets[]` contains an `(os, cpu, libc)` triple
  matching the selector. Targets-array scan is linear because real
  archives ship 1–3 target entries per variant; quadratic cost is
  immaterial.

- `libc_matches(variant_libc, requested_libc)` is the asymmetric
  helper from
  [`libcMatches`](https://github.com/pnpm/pnpm/blob/94240bc046/resolving/resolver-base/src/index.ts#L100-L107).
  Crate-private so the matching policy isn't part of the public API.

Lives in `pacquet-lockfile` next to the variant types (mirrors
upstream's `resolver-base` package boundary). The host-platform side
(constructing the `PlatformSelector` from `pacquet-graph-hasher`'s
existing `host_platform/arch/libc` helpers) lands at the install
dispatcher in Slice D — keeps `pacquet-lockfile` free of the
`graph-hasher` dep and lets tests drive the picker with synthetic
hosts.

Tests: first-match wins, multi-target variant matches any host triple,
no-match returns `None`, musl-host rejects glibc-default variant,
musl-host matches musl-annotated variant, and a six-row `libc_matches`
truth table (None / "glibc" / "musl" / unknown-future-libc on both
sides) pinning the asymmetric contract from upstream.

Part of #437.

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

* docs: refresh stale references + add declaration-order tie-breaker test

Two follow-ups from Copilot review on PR #466:

- `resolution.rs:152-154` claimed "the variant picker checks at
  runtime that the resolved inner is atomic", but
  `select_platform_variant` does not. Reword to describe the actual
  contract: pacquet's `PlatformAssetResolution.resolution` is
  typed as the full `LockfileResolution` for serde uniformity, and
  the lockfile is trusted to honor upstream's atomic-inner
  invariant. No infinite-recursion risk because the install
  dispatcher doesn't call back into `select_platform_variant` for
  non-`Variations` inputs.

- `resolution.rs:169` referenced `pick_variant` in
  `pacquet-package-manager`, but the picker actually lives in this
  module as `select_platform_variant`. Updated the pointer.

- `pick_returns_first_when_multiple_variants_match` test: two
  variants both list the same `(darwin, arm64)` target; the test
  asserts the first one wins, pinning the `Array.prototype.find`
  semantics. Pnpm-written lockfiles can rely on declaration order
  (e.g., listing a preferred build before a fallback) — without
  this test, a future refactor that switched the iteration to a
  triple-keyed `BTreeMap` would silently break that.

No behavior change; doc-comment text + test addition.

---
Written by an agent (Claude Code, claude-opus-4-7).
2026-05-13 18:34:30 +02:00
Zoltan Kochan
e2bebb1eb1 feat(lockfile): BinaryResolution + VariationsResolution (#437 slice A) (#457)
## Summary

Slice A of #437. Adds the lockfile types pnpm v11 writes for runtime dependencies (`node@runtime:`, `deno@runtime:`, `bun@runtime:`); the install pipeline doesn't dispatch them yet (subsequent slices). No new workspace deps.

- **`BinaryResolution { url, integrity, bin, archive, prefix? }`** — mirrors upstream's [`BinaryResolution`](https://github.com/pnpm/pnpm/blob/94240bc046/resolving/resolver-base/src/index.ts#L41-L49). `bin` is `BinarySpec::Single(String) | BinarySpec::Map(BTreeMap)` (untagged); `archive` is the lowercase `BinaryArchive::Tarball | BinaryArchive::Zip` discriminator; `prefix` (only set on the `.zip` branch upstream) skips serialization when `None`.
- **`PlatformAssetTarget { os, cpu, libc? }`** + **`PlatformAssetResolution { resolution, targets }`** per [`resolver-base`](https://github.com/pnpm/pnpm/blob/94240bc046/resolving/resolver-base/src/index.ts#L60-L69). `libc` is `Option<String>` (not an enum) so an upstream addition beyond the current `musl` value lands without a serde migration.
- **`VariationsResolution { variants }`** + the `LockfileResolution`/`TaggedResolution` arms routing both new shapes through the existing `from/into ResolutionSerde` round-trip.
- **Lockfile major stays at 9** — confirmed against [`core/constants/src/index.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/core/constants/src/index.ts) (`LOCKFILE_MAJOR_VERSION = '9'`), so the existing `lockfile_version.major == 9` assertion holds for v11 lockfiles carrying runtime entries.

**Install-dispatch stubs.** `install_package_by_snapshot.rs` and `create_virtual_store.rs` raise `InstallPackageBySnapshotError::UnsupportedResolution { kind: "binary" | "variations" }` until Slice D wires the fetcher. Adding these arms keeps the workspace compiling without forcing the full install path to land in this slice. The warm-prefetch path returns `Ok(None)` for `Variations`, routing through the cold dispatcher where the unsupported-kind error fires; upstream unwraps `Variations` before this layer ever sees one, so the branch is unreachable on well-formed input.
2026-05-13 17:56:05 +02:00
Zoltan Kochan
0a89e132fc feat: add hoisting support (hoistPattern + publicHoistPattern) (#435) (#445)
Closes #435.

## Summary

- Ports `@pnpm/config.matcher` (`*` glob, `!` negation, first-match-wins) into a new `pacquet_config::matcher` module — no `regex` dependency.
- Ports `getHoistedDependencies` and `symlinkHoistedDependencies` from upstream `installing/linking/hoist/src/index.ts` into a new `pacquet_package_manager::hoist` module. BFS by depth + lex on nodeId, public wins ties, first-seen wins per alias, root importer's direct-dep names seed the "already hoisted" set.
- Wires the hoist pass into `InstallFrozenLockfile::run` between the per-slot bin link and the `importing_done` emit. Result is threaded through `Install::run` into `.modules.yaml`'s `hoistedDependencies`.
- Promotes `Config.hoist_pattern` and `Config.public_hoist_pattern` to `Option<Vec<String>>` so the install-time guard mirrors upstream's `!= null` check (issue §D).

Upstream pinned at pnpm v11 [`94240bc046`](https://github.com/pnpm/pnpm/tree/94240bc046).

## Tests

- **Matcher (`crates/config/src/matcher.rs`)** — 6 tests, including direct ports of upstream's `matcher()` and `createMatcherWithIndex()` test cases plus regex-special-char-literal coverage.
- **Hoist algorithm (`crates/package-manager/src/hoist/tests.rs`)** — 12 unit tests covering empty graph, default `*` pattern, public hoist, public-wins-ties, negation, first-seen-wins, root direct-dep blocking, skipped-snapshot exclusion, bin-set collection, and graph-builder + direct-deps-builder helpers.
- **End-to-end (`crates/cli/tests/hoist.rs`)** — 9 integration tests using pnpm to generate the lockfile and pacquet to install with `--frozen-lockfile`. Cover default private hoist, `publicHoistPattern: '*'`, both-empty, `shamefullyHoist` legacy, scoped-pattern filter, `!`-negation, `.modules.yaml` round-trip, and bin linking on both private and public sides.
- **Known failures (17 stubs)** — Ports the remainder of `installing/deps-installer/test/install/hoist.ts` into `known_failures` modules via `pacquet_testing_utils::allow_known_failure!`. Each stub names the upstream URL line + the feature blocking it (workspace install #431, partial install #433, skipped optional deps, direct-dep bin precedence, `extendNodePath`).

`plans/TEST_PORTING.md` updated to mark every ported `hoist.ts` entry with the corresponding pacquet test name.
2026-05-13 17:53:46 +02:00
Zoltan Kochan
918e79176b test(git-fetcher): port §E git-fetcher and tarball-fetcher tests from upstream (#436 §E) (#462)
Ports five new tests against the local-bare-repo and write-to-CAS fixtures already established by §B+D and §C, plus the `plans/TEST_PORTING.md` checkbox updates for the §E subset that ships today (15 items checked off; the rest carry concrete deferral reasons — Stage 2 resolver work, real-npm requirements, perf benchmarks, or follow-up tests that need a PATH-shim helper).

### New tests in `crates/git-fetcher/src/fetcher/tests.rs`

- `fetcher_packs_subfolder_when_path_set` — ports [`fetching/git-fetcher/test/index.ts:69`](https://github.com/pnpm/pnpm/blob/94240bc046/fetching/git-fetcher/test/index.ts#L69). Builds a tiny monorepo bare repo, runs `GitFetcher` with `path: Some("packages/sub")`, asserts the returned `cas_paths` only contain files relative to the sub-dir (never sibling packages, never carrying the `packages/` prefix).
- `fetcher_handles_repo_without_package_json` — ports [`fetching/git-fetcher/test/index.ts:150`](https://github.com/pnpm/pnpm/blob/94240bc046/fetching/git-fetcher/test/index.ts#L150). A repo with no `package.json` at root still imports cleanly: `prepare_package` returns `should_be_built: false`, and the fetcher hands the install dispatcher the files the packlist found.
- `fetcher_skips_build_when_ignore_scripts` — ports [`fetching/git-fetcher/test/index.ts:247`](https://github.com/pnpm/pnpm/blob/94240bc046/fetching/git-fetcher/test/index.ts#L247). A repo whose `scripts.prepare` would fail if it ran (`exit 1`) still produces a successful fetcher run when `ignore_scripts: true`; observing success proves the lifecycle runner never spawned. Pins the `should_be_built: true` short-circuit so callers know the package wanted a build.

### New tests in `crates/git-fetcher/src/tarball_fetcher/tests.rs`

- `tarball_path_traversal_attack_is_rejected` — ports [`fetching/tarball-fetcher/test/fetch.ts:610`](https://github.com/pnpm/pnpm/blob/94240bc046/fetching/tarball-fetcher/test/fetch.ts#L610). `path: Some("../escape")` must surface as `INVALID_PATH` from `prepare_package`'s `safe_join_path` before any extraction.
- `tarball_path_to_missing_subdir_is_rejected` — ports [`fetching/tarball-fetcher/test/fetch.ts:637`](https://github.com/pnpm/pnpm/blob/94240bc046/fetching/tarball-fetcher/test/fetch.ts#L637). `path: Some("does/not/exist")` for a tarball that doesn't contain the sub-dir must surface as `INVALID_PATH` rather than silently packing the root.

### `plans/TEST_PORTING.md` updates

15 §E items moved from `[ ]` to `[x]` with the covering pacquet test name. Deferrals carry concrete reasons:

- **Real-npm dependency** (3 items): `fetch a package from Git that has a prepare script`, `fail when preparing a git-hosted package`, `allow git package with prepare script`. Defer until a `skip-if-no-npm` test helper exists.
- **PATH-shim helper required** (1 item): `still able to shallow fetch for allowed hosts`. The predicate is unit-tested; the end-to-end `fetch --depth 1` assertion needs a `git`-binary spy.
- **Stage 2 resolver work** (most install-level fromRepo tests + the 4 git-resolver tests).
- **Perf benchmarks** (2 items): `fetch a big repository`.

## Out of scope (still on #436)

- Two-slot CAS layout (`${filesIndexFile}\traw` + final).
- Real `npm-packlist` semantics (`.npmignore`, `.gitignore`, `bundleDependencies` walking).
- The Stage 2 / real-npm / PATH-shim deferrals noted above.
2026-05-13 17:51:20 +02:00
Zoltan Kochan
b53a50f037 feat(real-hoist): ancestor-path-aware peer-shadow refusal (#438) (#461)
## Summary

Lifts the previous `UnsupportedPeerDependency` upfront guard and replaces it with the real peer check from upstream's `getNodeHoistInfo`. The hoister now accepts lockfiles whose packages declare peer dependencies and produces the layout pnpm would: a peer-constrained dep only floats up to the root when doing so doesn't change which package its peers resolve to.

## How it works

- `HoisterResult` carries the input `peer_names` set forward. Upstream's `HoisterResult` doesn't (peer info lives on its intermediate `HoisterWorkTree`); pacquet runs the algorithm on `HoisterResult` directly, so the peer set rides along.

- The hoist driver is a recursive `hoist_subtree` that walks the result graph depth-first. Each recursion receives the candidate parent's *current* ancestor path (`Vec<Rc<HoisterResult>>` exclusive of the parent). After a child's hoist decision is applied, the path passed into the child's recursion reflects its *new* position — a freshly-hoisted child recurses with `[root]`, a child that stayed nested recurses with `parent_path + [parent]`. (An earlier version of this PR used BFS with paths captured at queue time; that path went stale whenever a node was hoisted between being queued and dequeued, leading to over-refused hoists. The recursive form was Copilot's recommendation in code review; the bug regression is pinned by `peer_check_uses_post_hoist_ancestor_path_not_queue_time_path`.)

- `would_shadow_peer` walks that path to decide whether a candidate hoist would change its own peer resolution.

- New `AbsorbDecision::PeerShadow` variant alongside `Free` / `SameNode` / `Conflict`. Fires when:
  1. Root declares the candidate's own name as a peer (root-shadow guard — vacuous today since the wrapper's `.` root has empty `peer_names`, kept for parity).
  2. For each peer name `P` the candidate declares, the closest ancestor providing `P` does so with a different ident than the root's `P`. Promoting the candidate would silently re-resolve the peer.

- The previous `UnsupportedPeerDependency` error variant and `find_first_peer_constrained` upfront scan are gone. The `#[non_exhaustive]` tag stays so future variants can be added without breaking callers.

## DAG-vs-tree caveat

Upstream's `cloneTree` duplicates the work tree into a strict tree per parent path, so each candidate visit has a unique ancestor chain. Pacquet preserves the DAG (its identity-dedup is a feature) and records only the path DFS actually used to reach the candidate. In the rare case where the same `Rc` is reachable through both a peer-compatible and a peer-shadowing path, pacquet refuses to hoist — the layout ends up at most over-nested, never under-nested. The trade-off is documented on `would_shadow_peer`.

## Upstream reference

- [`hoist.ts:414`](4287909fa6/packages/yarnpkg-nm/sources/hoist.ts (L414)) — root-shadow guard.
- [`hoist.ts:454-479`](4287909fa6/packages/yarnpkg-nm/sources/hoist.ts (L454-L479)) — ancestor-path peer check.
- [`hoist.ts:670`](4287909fa6/packages/yarnpkg-nm/sources/hoist.ts (L670)) — `cloneTree`, the per-path-tree shape pacquet skips.

## Test plan

- [x] `peer_constrained_node_stays_under_parent_when_root_provides_different_ident` — `app → widget (peer: react) + react@17`, `root → react@18`. widget cannot hoist because hoisting would change its peer to react@18.
- [x] `peer_constrained_node_hoists_when_ancestor_and_root_agree` — same shape but `app → react@18` matches root's `react@18` (shared `Rc` via wrapper dedup), so widget hoists freely.
- [x] `peer_check_uses_post_hoist_ancestor_path_not_queue_time_path` — regression for the BFS-stale-path bug Copilot caught. `root → app → mid → terminal` (peer: `react`) where `mid` hoists past a conflicting `app.react@17`. Under the previous BFS the stale path `[root, app, mid]` made `terminal`'s peer check trip on `app.react@17 ≠ root.react@18` and refuse the hoist; under DFS the actual post-hoist path `[root, mid]` finds no provider mismatch and `terminal` correctly flattens to root.
- [x] All 10 pre-existing tests still pass (`one_transitive_dep_hoists_to_root`, `diamond_dep_hoists_once_to_root`, `version_conflict_keeps_loser_at_parent`, `deep_chain_flattens_in_one_pass`, `transitive_npm_alias_resolves_target_snapshot`, `non_empty_hoisting_limits_surfaces_unsupported`, `multi_importer_lockfile_surfaces_unsupported_workspace`, `external_dependencies_are_stripped_from_the_result`, `empty_lockfile_yields_empty_root`, `hoist_throws_on_broken_lockfile`).
- [x] The previously-failing `peer_dependency_in_lockfile_surfaces_unsupported` test from #452 is replaced (peers are no longer an unsupported input).
- [x] All Copilot review threads addressed and resolved (stale ancestor path, misleading comment).
- [x] `just ready` (836 tests pass), `cargo doc --document-private-items`, `taplo format --check`, `just dylint` all clean.
2026-05-13 17:50:28 +02:00
Zoltan Kochan
970100131e feat: supportedArchitectures config + --cpu/--os/--libc CLI + real libc detection (#434 slice 2) (#456)
Slice 2 of the [#434 umbrella](https://github.com/pnpm/pacquet/issues/434) (`Proper optionalDependencies support`). Slice 1 (#439) wired `pacquet-package-is-installable` into the install pipeline but the `SupportedArchitectures` input was always `None`, so every install behaved as if the user had opted into \"host triple only\". This PR closes that gap and replaces the slice 1 libc stub with a real Linux probe.

Mirrors the three upstream input sources pinned at [pnpm/pnpm@94240bc046](https://github.com/pnpm/pnpm/tree/94240bc046):

- **`Config.supported_architectures` from `pnpm-workspace.yaml`** — same shape as upstream's `getOptionsFromRootManifest.ts` ([`config/reader/src/getOptionsFromRootManifest.ts#L23`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/getOptionsFromRootManifest.ts#L23)).
- **`--cpu` / `--os` / `--libc` flags on `install` / `add`** — multi-valued, comma-separated. Per-axis CLI override replaces the yaml axis wholesale, matching upstream's [`overrideSupportedArchitecturesWithCLI.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/overrideSupportedArchitecturesWithCLI.ts).
- **Real Linux libc detection** — replaces the slice 1 `\"unknown\"` stub with a `/lib*/ld-musl-*` probe cached via `OnceLock`. Mirrors `detect-libc.familySync()` semantics: `\"musl\"` / `\"glibc\"` on Linux, `\"unknown\"` everywhere else. No new dep — open-coded `read_dir` is cheaper than spawning `ldd --version` and works in slim containers without `ldd` on PATH.

Threaded through `Install` / `InstallFrozenLockfile` / `Add` as an explicit field instead of mutating `State.config`, because `State.config` is `&'static Config` — the CLI override merge happens at the CLI layer and the fully-resolved value lands on the install pipeline.

## Test plan

Unit tests added; full suite ran via `just ready` + `cargo doc --no-deps` + `taplo format --check` + `just dylint`.

- [x] `cargo test -p pacquet-config workspace_yaml::tests::*supported_architectures*` — yaml round-trip across `os` / `cpu` / `libc`, omission, partial-axis (3 tests).
- [x] `cargo test -p pacquet-package-manager --lib installability::tests::supported_architectures*` — `supportedArchitectures` widens the accept set so a darwin-only package stays on a linux host when the user lists `darwin`; conversely, listing `linux` keeps the darwin-only package skipped (no implicit host include).
- [x] `just ready` — typos + fmt + check + nextest (792 tests pass) + clippy.
- [x] `taplo format --check`.
- [x] `just dylint` (perfectionist).
- [x] `RUSTDOCFLAGS=\"-D warnings\" cargo doc --no-deps --workspace`.

## Out of scope

- `.modules.yaml.skipped` persistence (umbrella slice 3).
- Current-lockfile diffing for `removeOptionalDependenciesThatAreNotUsed` (umbrella slice 6).
- `--no-optional` plumbing (umbrella slice 5).

Closes #453.
2026-05-13 17:31:54 +02:00
Zoltan Kochan
cad3782c97 feat(git-fetcher): warm prefetch for git-hosted snapshots (#436 follow-up) (#454)
## Summary

Wires git-hosted installs into pacquet's existing `index.db` warm-cache path. Before this PR every git install cold-pathed, even when the snapshot was already in the store from a previous install. Now the second install of an unchanged lockfile reuses the previous install's clone + checkout + prepare + packlist work and skips the fetcher entirely — same as registry-tarball installs do today.

### Three pieces

1. **Both fetchers now write `PackageFilesIndex` rows.** `cas_io::import_into_cas` returns the full `HashMap<String, CafsFileInfo>` payload alongside the existing `cas_paths` map; the fetchers wrap that into a `PackageFilesIndex` and queue it on the shared `StoreIndexWriter` at the cache key the dispatcher passed in.
   - `algo: "sha512"`, `manifest: None`, `requires_build` reflects `should_be_built`.
   - `add_files_from_dir`'s `file_mode_from` is duplicated locally so the cache row encodes the same POSIX-mode shape pnpm's tarball entries use.
2. **`create_virtual_store::snapshot_cache_key` routes git arms through the warm batch.** For `Tarball { gitHosted: true }` and `Git` resolutions it now returns `Some(gitHostedStoreIndexKey(pkg_id, built=true))` (matches upstream's `pickStoreIndexKey` shape) instead of the previous `None` cold-batch shortcut. `built = true` matches the dispatcher's `ignore_scripts: false` default; both sites will flip together when an `ignore-scripts` config knob lands.
3. **Dispatcher threads writer + key into both fetchers.** `install_package_by_snapshot.rs` builds the same `gitHostedStoreIndexKey(package_id, built=true)` the warm path reads and hands it to `GitFetcher.files_index_file` / `GitHostedTarballFetcher.files_index_file`, plus the shared `store_index_writer` clone.

The warm prefetch's existing file-verification (`check_pkg_files_integrity`) runs unchanged — if any CAS file referenced by the row has been pruned, the snapshot falls through to cold and the fetcher re-runs.

## Out of scope

- Two-slot CAS layout (`${filesIndexFile}\traw` + final) for skipping the re-import when packlist == raw. Pure perf optimization on top of this PR. Tracked on #436.
- §E full integration-test matrix.
- Real `npm-packlist` semantics.
2026-05-13 17:01:40 +02:00
Zoltan Kochan
28046aab81 feat(real-hoist): single-pass parent-wins hoist (#438) (#452)
## Summary

Replaces the stub `nm_hoist` (landed in #448) with a working hoister and the surrounding guardrails: BFS over the result graph pulling every eligible descendant up to the root, plus upfront refusal of inputs the algorithm doesn't yet model.

### What the algorithm models

- **Free hoist.** A transitive dep with no name collision at the root surfaces at the root.
- **Identity dedup.** A dep reachable through multiple parents shares one `Rc` thanks to the wrapper's cache; the hoist preserves that identity and strips the duplicate reference at the other parent path.
- **Parent-wins on version conflict.** When two distinct deps share an alias but resolve to different snapshot keys, the first BFS visitor takes the root slot and the other stays under its declaring parent. Visit order matches the wrapper's alias-sorted insertion order, so the outcome is deterministic.
- **Deep chains flatten in one pass.** `root → a → b → c → d` becomes `root → {a, b, c, d}` — each node, once hoisted, is queued for further descent; its own children evaluate against root's slots rather than against the (now-empty) intermediate parent.
- **O(1) root-slot lookup.** A side `HashMap<String, RcByPtr<HoisterResult>>` mirrors root's direct deps, so the per-edge "is this name taken?" check doesn't degrade to O(N²) `IndexSet` scans on flat graphs.

### Fail-fast guards

The algorithm doesn't yet model peers, hoisting limits, or multi-importer (workspace) hoist trees. Rather than emit a layout pnpm would reject, the wrapper refuses these inputs upfront with three new `HoistError` variants:

- `UnsupportedPeerDependency { ident, peers }` — fires when scanning the constructed `HoisterTree` finds any node with non-empty `peer_names` (either `peerDependencies` from the `packages:` map or `transitive_peer_dependencies` on a `snapshots:` entry).
- `UnsupportedHoistingLimits { len }` — non-empty `opts.hoisting_limits`.
- `UnsupportedWorkspace { extra_importers }` — any importer beyond the root `.`.

Each carries enough context for an operator to identify what triggered it. The `UnsupportedWorkspace` help points at `SymlinkDirectDependencies` — workspaces *do* work in pacquet's wider install path (workspace support landed in #443 for the isolated linker); only the hoister is restricted.

### Rebase pickup

`collect_importer_deps` carries the `Link`-variant skip introduced by #443 (workspace support) — `spec.version.as_regular()` extracts the snapshot key for `Regular` deps and `continue`s past `Link` deps, since workspace siblings are direct symlinks materialised by `SymlinkDirectDependencies` and have no snapshot to hoist.

### Performance

Nothing in this PR is reachable from `pacquet install` today (`crates/package-manager/src/install*.rs` doesn't import `pacquet-real-hoist` — the crate is dead code from the install pipeline's perspective until the hoisted-linker wiring lands in later slices). The benchmark results bear that out: cold Frozen Lockfile is within CI variance (+3.2% mean, +3.5% median, driven by one outlier), Hot Cache is 6% *faster* (same `stat → lstat` improvement that shipped in slice 1's `import_indexed_dir`), micro is identical. No code path explains a real regression.

## What's deferred

- Peer-dependency-aware hoisting (`peer_names` constraints, peer-promise satisfaction).
- Multi-round convergence — the BFS handles deep chains in one pass, so the cases requiring true multi-round are limited to peer interactions.
- `hoistingLimits` runtime enforcement.
- `dependencyKind` distinctions for workspaces and external soft links (today only `ExternalSoftLink` placeholders are added by the wrapper and stripped post-hoist; `Workspace`-kind nodes are blocked by the `UnsupportedWorkspace` guard upfront).

## Upstream reference

- [`hoist.ts` algorithm overview](4287909fa6/packages/yarnpkg-nm/sources/hoist.ts (L1-L51)) — the recipe pacquet's single-pass BFS approximates.
- [`hoistTo` main loop](4287909fa6/packages/yarnpkg-nm/sources/hoist.ts (L329)) — the structural intent the port mirrors for the subset above.
2026-05-13 16:58:20 +02:00
Zoltan Kochan
03499dd884 feat: activate global-virtual-store install path (#432) (#449)
## Summary

Threads `VirtualStoreLayout` (from #444) through the frozen-lockfile install pipeline so packages installed with `enableGlobalVirtualStore: true` (pacquet's default since #444) land at the upstream-shaped `<store_dir>/links/<scope>/<name>/<version>/<hash>/node_modules/<pkg>` path. Closes the rest of #432 (Section B + C activation + D entry tests).

## Pipeline changes (frozen-lockfile path)

- `InstallFrozenLockfile::run` constructs the install-scoped `VirtualStoreLayout` from the lockfile + engine name + config, then threads `&VirtualStoreLayout` through `CreateVirtualStore`, `CreateVirtualDirBySnapshot`, `create_symlink_layout`, `SymlinkDirectDependencies`, `InstallPackageBySnapshot`, `BuildModules`, and `LinkVirtualStoreBins`. Every site that used to compute `virtual_store_dir.join(key.to_virtual_store_name())` now goes through one `layout.slot_dir(key)` lookup.
- `Install::run` calls `pacquet_store_dir::register_project` **once per importer** when `enable_global_virtual_store` is on, writing `<store_dir>/projects/<short-hash>` per workspace package so a future `pacquet store prune` can find every reachable consumer of `<store_dir>/links/...`. Best-effort: registry write failures degrade to `tracing::warn!` and the install continues. The walk runs *after* `InstallFrozenLockfile::run` succeeds so importer keys have already been validated.
- `Config::current` now applies `apply_global_virtual_store_derivation` after yaml — pacquet's variant of upstream's [`extendInstallOptions.ts:343-355`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/extendInstallOptions.ts#L343-L355). An explicit `globalVirtualStoreDir:` in yaml wins over the derivation; otherwise the field falls back to the user's pinned `virtualStoreDir` (under GVS-on) or to `<store_dir>/links`.

## Field-split deviation from upstream

Upstream pnpm *mutates* `virtualStoreDir` in place under GVS, so every consumer that reads `virtualStoreDir` sees `<storeDir>/links`. Pacquet keeps `virtual_store_dir` at its project-local value and writes the GVS path into the separate `global_virtual_store_dir` field. The frozen-lockfile path consumes `global_virtual_store_dir` through `VirtualStoreLayout`; the legacy non-frozen `InstallWithoutLockfile` keeps reading `virtual_store_dir` (now via `VirtualStoreLayout::legacy`). The split is a pacquet-only concern: upstream has no without-lockfile install path and so doesn't need the second field. See the doc on `Config::apply_global_virtual_store_derivation` for the reasoning.

## Benchmarks

The integrated-benchmark fixture pins `enableGlobalVirtualStore: false` so existing scenarios continue to compare apples-to-apples against the project-local `<modules>/.pnpm/<flat-name>` shape. Without the pin, every revision newer than this PR would silently switch to the GVS layout, while `pacquet@main` and the pnpm side of the comparison would still use the isolated layout — different shape, unfair comparison. GVS-specific benchmark runs are available by passing `--fixture-dir <dir>` at a copy of the fixture with the flag flipped.

## Tests

- `install::tests::frozen_lockfile_under_gvs_registers_project_and_runs_clean` pins the registry write under default GVS-on (single-project case).
- `install::tests::frozen_lockfile_under_gvs_registers_each_workspace_importer` pins per-importer registration: workspace root + `packages/web` each end up with their own symlink in `<store_dir>/projects/`.
- `install::tests::frozen_lockfile_with_gvs_off_skips_project_registry` pins that GVS-off opts out of the registry write.
- `config::tests::yaml_global_virtual_store_dir_wins_over_derivation` pins the yaml-override precedence.
- All **829 workspace tests** pass under the new layout-threaded path.
- Existing per-snapshot legacy tests construct `VirtualStoreLayout::legacy(...)` explicitly so they keep asserting the flat-name layout regardless of any global default. Partial-install tests from #442 pin `enable_global_virtual_store = false` so their seeded legacy-shape slots match the probe path.

End-to-end port of upstream's [`installing/deps-installer/test/install/globalVirtualStore.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/test/install/globalVirtualStore.ts) is tracked as a follow-up — those tests need a small frozen-lockfile fixture against the registry-mock that doesn't exist yet.
2026-05-13 16:56:34 +02:00
Zoltan Kochan
f33838cdd1 feat: workspace support for pacquet install --frozen-lockfile (#431) (#443)
Stage 1 of pnpm/pacquet#299 / closes #431 (Stage 1 scope) / partially closes #357.

- **New crate `pacquet-workspace`** — ports pnpm's `workspace/root-finder`, `workspace/workspace-manifest-reader`, `workspace/projects-reader`, and `workspace/project-manifest-reader` (pinned to [`94240bc046`](https://github.com/pnpm/pnpm/tree/94240bc046)). `find_workspace_dir` honours `NPM_CONFIG_WORKSPACE_DIR` (and its lowercase spelling, with empty-value fall-through), walks up to `pnpm-workspace.yaml`, and rejects misnamed variants (`pnpm-workspace.yml`, `.pnpm-workspace.yaml`, ...) with `BAD_WORKSPACE_MANIFEST_NAME`. Project enumeration glob-expands `packages:` via `wax`, always includes the workspace root (https://github.com/pnpm/pnpm/issues/1986), filters `**/node_modules/**` + `**/bower_components/**`, sorts lex by `rootDir`, and preserves the omitted-vs-explicit-empty distinction so `packages: []` enumerates only the root rather than falling back to the recursive `['.', '**']` default.

- **Per-importer install path** — `SymlinkDirectDependencies` iterates every entry in `Lockfile.importers`, computes per-project `rootDir` and `node_modules/`, and uses `rootDir` as the `prefix` for `pnpm:root added` events (matching upstream's [`linkDirectDeps`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/direct-dep-linker/src/linkDirectDeps.ts#L131)). Install-wide events (`pnpm:stage`, `pnpm:summary`) still use `lockfileDir`, now resolved through `find_workspace_dir`. `MissingRootImporter` is gone — an empty importers map is a silent no-op. A custom `modulesDir` from `pnpm-workspace.yaml` propagates to every importer (the symlink stage reads `config.modules_dir.file_name()` rather than hard-coding `node_modules`).

- **Path-anchoring consistency** — `Config::current` re-anchors `modules_dir` / `virtual_store_dir` to the workspace root when `pnpm-workspace.yaml` is found in an ancestor, mirroring pnpm v11's `pnpmConfig.dir = lockfileDir`. Running `pacquet install` from a workspace subdirectory no longer produces two inconsistent `node_modules` layouts (subdir virtual store vs workspace-root per-importer symlinks). `Config::current` also honours `NPM_CONFIG_WORKSPACE_DIR` so the env-var override drives both `find_workspace_dir` and the path anchors. `workspace_root` is threaded through `InstallFrozenLockfile` as a real `&Path` (no lossy-UTF-8 round-trip), so non-UTF-8 filenames survive the install layer intact.

- **Lockfile `link:` support** — `ResolvedDependencySpec.version` is now an `ImporterDepVersion` enum (`Regular(PkgVerPeer) | Link(String)`), so a v9 workspace lockfile with `version: link:../shared` parses. The symlink stage resolves the link target against the importer's `rootDir` and points `node_modules/<name>` at the sibling project rather than the virtual store; `BuildModules`, `pacquet-real-hoist`, and the side-effects-cache write path all skip `Link` variants when collecting snapshot-key roots.

- **Importer-key validation** — rejects absolute POSIX paths, Windows drive prefixes, backslash separators, `..` segments, and the empty string with a typed `UnsafeImporterPath` error, so a malformed (or hostile) lockfile cannot make `Path::join` create `node_modules` outside the workspace root.

- **Symlink error propagation** — the prior `expect("symlink pkg")` is replaced with `try_for_each` + a `SymlinkDirectDependenciesError::SymlinkPackage` variant carrying the `importer_id`, `name`, and underlying `SymlinkPackageError`. A filesystem failure no longer crashes the rayon pool.

- **Adds `wax = 0.7.0`** to `[workspace.dependencies]` for project globbing.
2026-05-13 15:52:23 +02:00
Zoltan Kochan
5b4c090db7 fix(package-manager): add missing path field in installability test fixture (#455)
Landrace between #439 (installability tests fixture) and #451 (added
`path: Option<String>` to `TarballResolution`). Both merged green
against their own bases but main's tip won't compile:

    error[E0063]: missing field `path` in initializer of `TarballResolution`
      --> crates/package-manager/src/installability/tests.rs:56:49

Add the missing field with `path: None` so the struct literal matches
the current `TarballResolution` shape.
2026-05-13 15:51:46 +02:00
Zoltan Kochan
0f51f820df feat: frozen-lockfile freshness check (#447) (#450)
## Summary

Closes #447. Adds a `satisfies_package_manifest` check to pacquet's frozen-lockfile dispatcher so a stale `pnpm-lock.yaml` no longer silently installs the wrong shape of `node_modules`.

Before this, `pacquet install --frozen-lockfile` would happily materialize whatever the lockfile said, even when a dev had edited `package.json` (added, removed, or bumped a dep) without re-running the resolver. Upstream pnpm catches this at the dispatch site with `ERR_PNPM_OUTDATED_LOCKFILE` ([`pkg-manager/core/src/install/index.ts:808-832`](https://github.com/pnpm/pnpm/blob/94240bc046/pkg-manager/core/src/install/index.ts#L808-L832)); this PR ports the same gate.

### What's checked

Ported from upstream's [`satisfiesPackageManifest`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/verification/src/satisfiesPackageManifest.ts). Four phases, short-circuiting on the first failure:

1. **Flat-record specifier diff** over `dependencies ∪ devDependencies ∪ optionalDependencies`. Catches added/removed/modified deps in one bucket; rendered as a `SpecDiff` with per-bucket lists.
2. **`publishDirectory`** parity between the importer entry and the manifest's `publishConfig.directory`.
3. **`dependenciesMeta`** JSON equality between the importer and the manifest (with absent ≡ empty-object equivalence to match upstream's `?? {}` coercion).
4. **Per-field name-set + specifier match.** Catches same-name-same-specifier moves between fields the flat-record diff doesn't see (e.g. `react` moved from `dependencies` to `devDependencies`). Applies upstream's **precedence rule** (`optional` > `prod` > `dev`): a dep that appears in a higher-precedence manifest field is filtered out of the lower-precedence field's check, so a manifest listing the same dep in both prod and dev still satisfies a lockfile that records it under prod only.

Reasons are surfaced as typed `StalenessReason` variants (`SpecifiersDiffer`, `DepSpecifierMismatch`, `PublishDirectoryMismatch`, `DependenciesMetaMismatch`, `NoImporter`) so callers match on the discriminant rather than parsing format strings, and tests assert on shape rather than wording. The `SpecDiff::Display` impl handles singular/plural ("1 dependency was added" vs "2 dependencies were added") so user-facing CI output reads cleanly.

### New errors

- `InstallError::OutdatedLockfile { reason: StalenessReason }` — surfaced as `ERR_PNPM_OUTDATED_LOCKFILE` (miette code `pacquet_package_manager::outdated_lockfile`). Hint points at `pnpm install --lockfile-only` to regenerate.
- `InstallError::NoImporter { importer_id }` — distinguishes "lockfile file is missing" (`NoLockfile`) from "lockfile is present but has no importer entry for this project." Renders as `importers["{id}"]` for readability.

### Performance

Confirmed by hyperfine on a 1352-package fixture with 110-dep manifest (warm reinstall, `--warmup 2 --runs 10`):

| | mean | range |
|---|---:|---:|
| main (no check) | 573.5 ms | 554-603 |
| **PR (with check)** | **574.7 ms** | 549-602 |

Ratio 1.00 ± 0.05 — within noise. The check is pure-CPU map/set operations on string keys with no syscalls or async; ~50-200 μs for the alot7 manifest.

### Out of scope (matching the issue)

- Catalogs (pacquet has no catalog support yet).
- `auto-install-peers` pre-pass (pacquet has no separate auto-install-peers mode).
- `excludeLinksFromLockfile` and `link:` resolutions (#431 territory).
- Semver-range-satisfies check (lives in pnpm's `localTarballDepsAreUpToDate`, outside this gate).
- Multi-importer support (#431 workspace install — single-importer-only here).
2026-05-13 15:35:45 +02:00
Zoltan Kochan
48a134f770 feat(git-fetcher): install git-hosted tarballs via preparePackage + packlist (#436 §C) (#451)
## Summary

Builds on §B+D (#446). Adds the second half of the git-hosted install path: `TarballResolution { gitHosted: true }` lockfile entries — the ones whose tarballs land at `codeload.github.com` / `gitlab.com` / `bitbucket.org` archive endpoints — now run through a new `GitHostedTarballFetcher` after the existing `DownloadTarballToStore` settles their bytes into CAS.

Mirrors pnpm's [`gitHostedTarballFetcher.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts):

1. Materialize the CAS-resident files into a writable temp dir (`fs::copy` per file — hardlinking here would let prepare scripts mutate shared CAS entries).
2. Run the existing `prepare_package` port that §B+D shipped — same `<pm>-install` + `prepublish` / `prepack` / `publish` dispatch, same `GIT_DEP_PREPARE_NOT_ALLOWED` gate.
3. Run the existing minimal `packlist` over the prepared tree to drop source maps, test fixtures, etc. that ship in the raw archive but not in the published file set.
4. Re-import the filtered file set back into CAS and hand the `HashMap<String, PathBuf>` to `CreateVirtualDirBySnapshot`.

### Shared `cas_io` module

The CAS-import helpers (`import_into_cas`, `is_file_executable`, `map_write_cas`) plus a new `materialize_into` lift out of `fetcher.rs` into a shared `cas_io` module so both fetchers consume the same code path. No public-API change — helpers stay `pub(crate)`.

`cas_io` also gains a `join_checked(root, rel)` helper that rejects absolute paths, `..` components, root, and drive-prefix components before joining, so a malicious tarball entry can't escape `target_dir` / `pkg_dir`. Errors surface as `GitFetcherError::Io(InvalidInput)`. Both `materialize_into` and `import_into_cas` route through it, taking `rel` directly (forward slashes are valid on every platform — `Path::components()` recognises both `/` and `\` on Windows — so no per-file `String` allocation is needed).

### Supporting changes

- `TarballResolution.path: Option<String>` mirrors pnpm's `TarballResolution.path` so a lockfile pinning a git-hosted tarball from a monorepo (`path: packages/sub`) deserializes without tripping `deny_unknown_fields`. The fetcher threads this through to `prepare_package`'s `safe_join_path` so prepare and packlist run inside the right sub-directory.
- The install dispatcher hoists the `allow_build` closure and the `ScriptsPrependNodePath` conversion above the resolution match so both the `Git` arm and the new `gitHosted: true` post-pass borrow from the same named locals — no temp closures across `.await`.
- `InstallPackageBySnapshotError::GitFetch`'s doc now spells out that the variant covers failures from both fetchers (the git-CLI path and the git-hosted-tarball post-pass), since both share the same `GitFetcherError` taxonomy.

## Out of scope (follow-ups for #436)

- Warm prefetch for git-hosted slots — neither fetcher writes `gitHostedStoreIndexKey` rows to `index.db` yet, so a re-install re-runs clone + prepare + packlist. Correct, but slow on the second run.
- Two-slot CAS layout (`${filesIndexFile}\traw` + final). Upstream optimizes "raw == prepared" by promoting the raw entry in place. Pacquet always re-imports today; hash-dedup means duplicate work but not duplicate storage.
- §E — full integration-test matrix in `plans/TEST_PORTING.md` 563-572.
- Real `npm-packlist` semantics (`.npmignore`, `.gitignore` layering, `bundleDependencies` walking).
2026-05-13 15:27:16 +02:00
Zoltan Kochan
ca88affd39 feat(package-is-installable): platform + engine check, skip optional incompatibles (#434 slice 1) (#439)
Slice 1 of #434 — foundational installability gate. Ports
[`@pnpm/config.package-is-installable`](https://github.com/pnpm/pnpm/tree/94240bc046/config/package-is-installable)
and threads the resulting skip-set through every phase of the
frozen-lockfile install pipeline.

Closes #266 once shipped (covers the "install respects every snapshot
— no os/cpu/libc filter" gap). Does **not** close #434 — that umbrella
has six more slices to follow.

Upstream reference: pnpm/pnpm@94240bc046.

## What landed

### New crate: `pacquet-package-is-installable`

Ports the upstream `config/package-is-installable` package's three
helpers:

- `check_platform` (`Option<&[String]>` for each `os`/`cpu`/`libc`
  axis, plus a `SupportedArchitectures` override) — returns
  `Option<UnsupportedPlatformError>` matching upstream's
  `ERR_PNPM_UNSUPPORTED_PLATFORM` code, message shape, and JSON
  payload. Handles negation entries (`!foo`), the `any` sentinel,
  the `current` placeholder, and the `currentLibc !== 'unknown'`
  skip from `checkPlatform.ts:38`.
- `check_engine` — evaluates `engines.node` / `engines.pnpm` via
  `node-semver`. Approximates npm-semver's `includePrerelease: true`
  via a strip-prerelease fallback; one over-acceptance edge case
  (`>=X.Y.Z` against `X.Y.Z-rc1`) is pinned in the known-failures
  integration test for follow-up.
- `package_is_installable` — composes the two, returns the
  tri-state verdict matching upstream's `boolean | null` (Installable
  / SkipOptional / ProceedWithWarning), plus an `Err` arm for
  `engine_strict` aborts and `ERR_PNPM_INVALID_NODE_VERSION`.

`InstallabilityOptions<'a>` borrows its host strings so a caller
running through many snapshots in a row can build the host part
once and only toggle `optional` per snapshot. `WantedPlatformRef<'a>`
plays the same role for the manifest axes so `check_platform` runs
the happy path without any allocation.

### New module: `pacquet_package_manager::installability`

`compute_skipped_snapshots` is the per-install entry point. For each
snapshot:

1. Look up the matching `PackageMetadata`.
2. Run `check_package` (cached per peer-stripped `metadata_key` so
   peer-resolved variants of the same package share one verdict).
3. Dispatch on `(verdict, snapshot.optional, host.engine_strict)`:
   - `Installable`: nothing to do.
   - `SkipOptional` + `optional`: add to `SkippedSnapshots`, emit
     `pnpm:skipped-optional-dependency` (deduped per metadata key,
     matching upstream's emit-per-pkgId).
   - `Incompatible` + non-optional + `engine_strict`: abort.
   - `Incompatible` + non-optional + non-strict: `tracing::warn!`
     and proceed. (Upstream's `pnpm:install-check` channel isn't
     wired into pacquet's reporter yet — slice 1 follow-up.)

`any_installability_constraint(packages)` is the caller-side fast
path: if no metadata row declares an `engines.{node,pnpm}` or a
non-empty / non-`["any"]` `cpu`/`os`/`libc`, the entire installability
pass is skipped. The probe runs synchronously in
`install_frozen_lockfile` *before* the `tokio::task::spawn_blocking`
that would invoke `node --version` — so the common no-constraints
lockfile pays nothing for the new pipeline, restoring main's overlap
of node-binary startup with extraction.

### Install-pipeline plumbing

The `SkippedSnapshots` set is threaded into every downstream phase
of `InstallFrozenLockfile::run`:

- `CreateVirtualStore`: installability-skipped snapshots are
  dropped from both `survivors` (no virtual-store slot extracted)
  and `skipped_entries` (no warm-cache row). Layered ahead of
  main's #442 already-installed-and-on-disk skip filter.
- `SymlinkDirectDependencies`: a direct dep whose resolved
  snapshot is in the skip set is omitted from `node_modules/<name>`
  (no symlink, no `pnpm:root added` event, no bin link).
- `LinkVirtualStoreBins`: per-slot bin link skips slots whose
  snapshot is installability-skipped (their virtual-store
  directories don't exist).
- `BuildModules` via `build_sequence`: `get_subgraph_to_build`
  consults `skipped` *before* recursion, so a skipped snapshot's
  subtree doesn't contribute to the build graph via that edge.
  Descendants reachable from a non-skipped root still build
  normally.

### Performance

CI integrated-benchmark on the 1352-package fixture, latest run:

| Scenario | `pacquet@HEAD` | `pacquet@main` | Relative |
|---|---|---|---|
| Frozen Lockfile (cold) | 2.476 ± 0.083 s | 2.442 ± 0.071 s | 1.01 ± 0.05 |
| Frozen Lockfile (Hot Cache) | 685.8 ± 59.3 ms | 700.2 ± 47.4 ms | 1.00 |

Earlier iterations of this PR showed a ~5% cold-install regression
from the `node --version` spawn landing on the extraction critical
path. Closed by hoisting the no-constraints fast-path probe to the
caller (commit `cf47ce51`) so the spawn is gated on actual
constraint presence.

Other perf passes folded in:

- `compute_skipped_snapshots` caches the per-metadata-row check
  verdict so peer-resolved variants share one `check_package` call.
- `check_platform` borrows its three wanted axes through
  `WantedPlatformRef<'a>`; the owned `WantedPlatform` only
  materialises in the error path.

## Tests

| Suite | Count | What it covers |
|---|---|---|
| `pacquet-package-is-installable::tests::check_platform` | 16 | Port of upstream `checkPlatform.ts` — `any`/`current` sentinels, negation, `supportedArchitectures` override, libc unknown-skip |
| `pacquet-package-is-installable::tests::check_engine` | 7 | Port of upstream `checkEngine.ts` — node/pnpm range checks, prerelease cases, `ERR_PNPM_INVALID_NODE_VERSION` |
| `pacquet-package-is-installable::tests::package_is_installable` | 6 | Tri-state verdict + optional/engine-strict dispatch |
| `pacquet-package-is-installable::tests::known_failures` | 1 | The `>=X.Y.Z` vs `X.Y.Z-rc1` over-acceptance, picked up by `just known-failures` |
| `pacquet_package_manager::installability::tests` | 11 | Per-install skip-set computation: skip on bad OS, skip on bad node engine, dedup events across peer variants, fast-path triggers, constraint predicate's edge cases (`engines.npm` only, `["any"]` sentinel, empty lists) |
| `pacquet_package_manager::build_sequence::tests` | 3 (new) | Skipped+patched doesn't enter build queue; skipped parent doesn't drag descendants in; descendant with non-skipped parent still builds |

All ported tests verified to catch regressions by temporarily
breaking the subject under test, observing the failure, then
reverting. The "test the tests" workflow from CLAUDE.md.

## Deferred to follow-up slices

- `.modules.yaml.skipped` write/read + headless re-check
  (slice 3).
- `supportedArchitectures` config + `--cpu` / `--os` / `--libc`
  CLI flags (slice 2).
- `pnpm:install-check` warn channel on the reporter side
  (currently `tracing::warn!`).
- Real libc detection — `host_libc()` returns `"unknown"` today;
  matches non-Linux host behavior, but on Alpine/musl this
  over-installs glibc-only optional packages. Slice 2.
- `engine_strict` config wiring — defaults to false today, so
  the error path is unreachable from production. Wired through
  end-to-end so the slice that flips the config doesn't churn
  the error enum.
2026-05-13 15:13:32 +02:00
Zoltan Kochan
429a9179c8 feat(real-hoist): crate skeleton + lockfile-to-HoisterTree wrapper (#438 slice 3a) (#448)
* feat(real-hoist): crate skeleton + lockfile-to-HoisterTree wrapper (#438 slice 3a)

First sub-slice of the Slice 3 hoister port from the umbrella. Lands
the IO surface and the pnpm-side wrapper; the inner `@yarnpkg/nm`
algorithm is stubbed (returns the input tree unchanged) and replaced
in 3b.

New crate `pacquet-real-hoist`:

- `HoisterTree` / `HoisterResult` / `HoisterDependencyKind` /
  `HoistingLimits` / `HoistOpts` / `HoistError`, mirroring upstream's
  `@yarnpkg/nm` types and the pnpm wrapper's option object.
- `RcByPtr<T>` wrapper providing identity-hashed `Rc<T>` so children
  stored in `IndexSet` keep JS `Set<HoisterTree>` semantics (a node
  added via two parent paths stays shared) without paying the cost
  of structural hashing.
- `hoist(&Lockfile, &HoistOpts)` — ports the wrapper at
  `installing/linking/real-hoist/src/index.ts`. Builds the
  HoisterTree from the lockfile (root importer's
  dependencies/devDependencies/optionalDependencies merged, then
  recursive descent through `snapshots`), adds workspace importer
  children, plus `link:` placeholders for `externalDependencies`,
  runs the inner stub, and post-filters `externalDependencies` out
  of the top-level result.
- `LockfileMissingDependency` error surfaced as a `miette` diagnostic
  with code `ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY`, matching the
  upstream error code at https://pnpm.io/errors.

Five unit tests pin the wrapper's observable behaviour:

- The "broken lockfile" error case is a direct port of pnpm's
  `installing/linking/real-hoist/test/index.ts` test.
- An empty lockfile yields an empty root.
- A `root → a → b` lockfile, under the stub, gives `root → a → b`
  (nothing hoisted). Pinning this means 3b's algorithm replacement
  is observably correct — the tree shape will change to
  `root → {a, b}` once real hoisting lands.
- A diamond dep (`root → {a, c} → b`) keeps `b` as one identity
  through both parents (proves the wrapper's dedup cache).
- `external_dependencies` are stripped from the result.

Upstream references pin `pnpm/pnpm@94240bc0` and
`yarnpkg/berry@4287909fa6`.

* docs(real-hoist): drop slice/umbrella scaffolding from code comments

Slice and sub-slice numbering is PR-organisation scaffolding — it's
useful in commit messages and PR descriptions but it rots inside
committed code the moment the referenced slices land, get renumbered,
or are abandoned. Rewrite the relevant doc-comments to describe what
the code *is* today rather than where it sits in a multi-PR sequence:

- Top-level module doc drops the "This is Sub-slice 3a of #438" intro.
- `hoist` doc drops the "Sub-slice 3a pins / later sub-slices replace"
  framing.
- `nm_hoist` stub doc drops "Sub-slice 3b replaces this".
- `external_dependencies` / non-root importer comments drop their
  "umbrella's Slice 10 / Slice 9" pointers.
- One test doc rewords "Sub-slice 3b's algorithm replacement is
  observably correct" into a description of what the test pins
  without naming the future slice.

* fix(real-hoist): aliased snapshot lookup, cycle-safe rc identity, non_exhaustive HoistError (#448 review)

Address four Copilot review findings on the new crate:

1. `collect_snapshot_deps` looked up `SnapshotDepRef::Alias` deps
   under the alias name instead of the resolved target name. The
   snapshot key for an npm-alias is `<target>@<ver>`, not
   `<alias>@<ver>`, so any real aliased transitive would have
   surfaced as `LockfileMissingDependency`. `build_dep_node` now
   takes the resolved `&PkgNameVerPeer` directly and the caller
   builds it via `dep_ref.resolve(alias)` for snapshot deps or
   `PkgNameVerPeer::new(alias, spec.version)` for importer deps.

2. `build_dep_node`'s cycle handling re-allocated the node on the
   way out (placeholder Rc, recurse, *new* finished Rc) which left
   any cycle-visiting Rc pointing at the empty placeholder.
   `HoisterTree::dependencies` is now a `RefCell<IndexSet<...>>` and
   the construction populates the cell in place — the placeholder
   and the populated node are the same allocation, so a back-edge
   reads the eventually-populated set. Same JS `Set<HoisterTree>`
   mutation semantics the real hoist algorithm needs.

3. `convert` had the same bug and gets the same fix on
   `HoisterResult::dependencies` and `HoisterResult::references`.
   The stub's traversal now collects the input children into a Vec
   before recursing so the borrow on the input cell drops, and
   populates the output cell in place.

4. `HoistError` is now `#[non_exhaustive]`, matching the rest of
   pacquet's public error enums.

A new regression test (`transitive_npm_alias_resolves_target_snapshot`)
pins the fix for (1): the snapshot key the wrapper looks up matches
the target package, the node's exposed `name` stays the alias, and
`ident_name` / `reference` carry the resolved target's identity.

Existing tests updated to take `Ref<'_, …>` via `.borrow()`. All six
pass; `just ready`, `cargo doc --document-private-items`, `taplo`,
and `just dylint` clean.
2026-05-13 14:33:18 +02:00
Zoltan Kochan
540fa7664a feat(git-fetcher): install git-hosted packages via git CLI + preparePackage (#436 §B+D) (#446)
Builds on §A (#440). Adds a new `pacquet-git-fetcher` crate that handles `LockfileResolution::Git` snapshots during frozen-lockfile installs, plus the supporting `preparePackage` port. The install dispatcher now routes git resolutions through it instead of returning `UnsupportedResolution { resolution_kind: "git" }`.

### New crate `pacquet-git-fetcher`

- **`GitFetcher`** shells out to the system `git` (clone, or `init` + `fetch --depth 1` on hosts in `git_shallow_hosts`), checks out the pinned commit, verifies via `rev-parse HEAD`, runs `preparePackage`, removes `.git`, computes a packlist, and imports the resulting file set into the CAS. Surfaces a friendly `GitNotFound` when `git` isn't on `PATH` — pacquet does not bundle it. Mirrors [`fetching/git-fetcher/src/index.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/fetching/git-fetcher/src/index.ts).
- **`prepare_package`** ports [`exec/prepare-package/src/index.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/exec/prepare-package/src/index.ts). Reads the manifest, decides via `package_should_be_built`, honors an `AllowBuildFn` (the dispatcher adapts pacquet's `AllowBuildPolicy` into this shape), runs `<pm>-install` plus any `prepublish` / `prepack` / `publish` scripts via the existing `pacquet_executor::run_lifecycle_hook`, then deletes `node_modules`. Emits the upstream error codes `GIT_DEP_PREPARE_NOT_ALLOWED`, `ERR_PNPM_PREPARE_PACKAGE`, `INVALID_PATH`.
- **`detect_preferred_pm`** sniffs the cloned tree for a lockfile to pick the install pm. Workspace-root walking deferred.
- **Minimal `packlist`** honors the manifest's `files` field with a tiny `*` / `**` / `?` glob matcher (`?` and single `*` reject `/`; `**` matches across directories), always-includes the README / LICENSE / package.json set, and always-excludes `.git`, `node_modules`, lockfiles for sibling pms, and common cruft. Full `.npmignore` / `.gitignore` semantics and `bundleDependencies` walking are deferred and tracked in module docs.

### Wiring in `pacquet-package-manager`

- `InstallPackageBySnapshot` now matches `LockfileResolution::Git` and runs the fetcher in place of `DownloadTarballToStore`.
- `&AllowBuildPolicy` is computed once per install in `InstallFrozenLockfile` and threaded through `CreateVirtualStore` → `InstallPackageBySnapshot`.
- `snapshot_cache_key` returns `Ok(None)` for `Git` resolutions so they cold-batch. Warm prefetch for git-hosted slots is a follow-up alongside §C.

### Supporting changes

- `GitResolution.path: Option<String>` so a lockfile pinning a sub-directory of a git-hosted monorepo deserializes without tripping `deny_unknown_fields`.
- `Config::git_shallow_hosts` with pnpm's default list + `pnpm-workspace.yaml` override.
- `pacquet_executor::run_lifecycle_hook` is now `pub` so the preparePackage port can dispatch by arbitrary stage name.

## Out of scope (follow-ups for #436)

- §C — git-hosted *tarball* fetcher (the `gitHosted: true` shape).
- §E — full integration-test matrix (`plans/TEST_PORTING.md` 563-572). This PR ships crate-level tests against a local bare repo that prove the path works end-to-end.
- Warm prefetch for git resolutions (re-clones on every install today — slow but correct).
- Full `npm-packlist` semantics (`.npmignore`, gitignore layering, `bundleDependencies` walking).
2026-05-13 14:16:28 +02:00
Zoltan Kochan
725c51e57e feat: partial install with --frozen-lockfile (#433) (#442)
## Summary

Closes #433. Implements partial install with `--frozen-lockfile`:

- **Read** `<virtual_store_dir>/lock.yaml` at install start (`Lockfile::load_current_from_virtual_store_dir`), mirroring upstream pnpm v11's `readCurrentLockfile`.
- **Skip** any snapshot whose `dependencies`, `optionalDependencies`, and `resolution.integrity` match the current lockfile **and** whose virtual-store slot exists on disk. No fetch, no extract, no relink for skipped snapshots.
- **Write** `<virtual_store_dir>/lock.yaml` at end-of-install (atomically; deletes the file when the lockfile is empty), so the next install sees a fresh diff target.
- **Emit** `pnpm:_broken_node_modules` (`BrokenModulesLog`) when the slot is gone despite the cache key matching, then fall through to a full install for that snapshot.
- `pnpm:context.currentLockfileExists` now reflects reality (was hard-coded `false`); `pnpm:stats.added` reports the post-skip delta instead of the total snapshot count, matching upstream.
- **Side-effects-cache prefetch still covers skipped snapshots** so `BuildModules`'s `is_built` gate fires on warm reinstalls — without this, packages with `allowBuilds: true` re-execute their lifecycle scripts every install (added in 1603f94f after benchmarking caught the regression — see [benchmark comment](https://github.com/pnpm/pacquet/pull/442#issuecomment-4440371477)).

Tracks pnpm v11 `94240bc046` for `readCurrentLockfile` / `writeCurrentLockfile` / `lockfileToDepGraph`'s per-`depPath` skip and `isIntegrityEqual` helper.

## Performance

Two scenarios benchmarked against a 1352-package fixture, hyperfine `--warmup 2 --runs 10`.

**Pure warm reinstall** (every snapshot intact, full `lock.yaml` on disk):

| | mean | sys time |
|---|---:|---:|
| baseline (`main` HEAD~2) | 969 ms | 985 ms |
| **PR** | **896 ms** | **349 ms** |

PR is 1.08× faster, with ~65% less kernel work — the skip filter bypasses the warm-batch's per-snapshot syscalls (1352 → 0). [Full breakdown + the regression we caught + fixed.](https://github.com/pnpm/pacquet/pull/442#issuecomment-4440371477)

**Non-up-to-date `node_modules`** (N virtual-store slots deleted per iteration):

| Damaged slots | PR | Baseline | PR/Baseline |
|---:|---:|---:|---:|
| 0 | 581 ms | 661 ms | **1.14×** |
| 1 | 615 ms | 707 ms | **1.15×** |
| 10 | 660 ms | 707 ms | 1.07× |
| 100 | 2.25 s | 2.25 s | 1.00× |
| 500 | 6.13 s | 7.93 s | **1.29×** |

PR is consistently a small win and never a loss across the damage spectrum; `_broken_node_modules` debug events fire correctly for each missing slot. [Full sweep + caveats.](https://github.com/pnpm/pacquet/pull/442#issuecomment-4440646688)
2026-05-13 13:54:56 +02:00
Zoltan Kochan
2f146d838c feat(package-manager): force + keep_modules_dir on package import (#438 slice 1) (#441)
Extends pacquet's package-import primitive to cover pnpm's
`importIndexedDir(..., { force, keepModulesDir })` call shape so the
hoisted node-linker (the umbrella of #438) has a primitive to build on.

- Renames `create_cas_files` → `import_indexed_dir` to match
  pnpm's name (its docstring already said it mirrored
  `tryImportIndexedDir`), and adds an `ImportIndexedDirOpts` struct
  with `force` and `keep_modules_dir`. Both linkers now go through
  the same function — pnpm uses one function for both, so does
  pacquet.
- **Default opts** (`force: false, keep_modules_dir: false`): the
  isolated linker's behavior. Existing target short-circuits; fresh
  target gets `mkdir` + parallel `link_file`. Unchanged hot path
  for the two existing call sites
  (`create_virtual_dir_by_snapshot`, `install_package_from_registry`).
- **`force: true`**: stage the new contents in a sibling directory,
  remove the old tree, rename stage into place. A regular file or
  symlink occupying the target is unlinked first.
- **`force: true` + `keep_modules_dir: true`**: before the swap,
  `dir_path/node_modules/` is moved into the staging directory so
  nested deps survive the rebuild. On any failure after the move,
  the staged copy is restored to `dir_path/node_modules/` before
  staging is cleaned up — staging never holds the user's only copy
  of nested deps. This is the call shape the hoisted linker will
  use.
- No install-pipeline wiring for hoisted yet. Subsequent slices
  (`linkHoistedModules` analog, hoist algorithm, `Install::run`
  branch on `node_linker`) will pass `force + keep_modules_dir`
  from the new hoisted path. The default-opts behavior is exercised
  in production today via the existing isolated callers.

## Upstream reference

Ported from `pnpm/pnpm@94240bc0`:

- [`fs/indexed-pkg-importer/src/importIndexedDir.ts`](94240bc046/fs/indexed-pkg-importer/src/importIndexedDir.ts) — the function this ports.
- [`store/controller-types/src/index.ts`](94240bc046/store/controller-types/src/index.ts) — the `ImportOptions` shape `ImportIndexedDirOpts` mirrors.
- [`installing/deps-restorer/src/linkHoistedModules.ts:134`](94240bc046/installing/deps-restorer/src/linkHoistedModules.ts (L134)) — the call site that will drive the `force + keepModulesDir` combination once the later slices land.

## Scope omissions (intentional)

- `moveOrMergeModulesDirs` semantics for the case where the indexed
  file map itself contains `node_modules/` entries. Upstream merges;
  pacquet errors with `NodeModulesCollision`. The hoisted-linker call
  site never produces that state in practice (npm and pnpm strip
  `node_modules/` at pack time), so erroring loudly until a real
  caller demands the merge is the right call.
- No `safeToSkip` short-circuit. Upstream's pre-existence check
  beyond the default short-circuit lives in `index.ts` around the
  importer; that decision belongs at the Slice 5 call site, not in
  this primitive.

## Performance

The isolated-linker hot path's syscall count is unchanged. The old
`if dir_path.exists() { return Ok(()); }` (one `stat`) becomes
`fs::symlink_metadata(dir_path)` (one `lstat`) plus a match
dispatch; `populate_dir` is the old `create_cas_files` body
verbatim and gets inlined under release LTO. The new `force`
branches only run for hoisted, which has no callers yet.

One behavior diff: a dangling symlink at the target under default
opts used to fall through to `mkdir` and surface an IO error;
under the new code it short-circuits as "already populated". The
virtual store doesn't produce dangling symlinks under normal
operation, so this is theoretical.
2026-05-13 13:43:54 +02:00
Zoltan Kochan
00d13515f2 feat: global-virtual-store foundation (#432 partial) (#444)
Pacquet port of upstream pnpm's global-virtual-store (GVS) machinery — lands the config fields, the store-dir helpers, the graph-hasher path math, and a `VirtualStoreLayout` helper, but **not** the install-pipeline wiring. Carves the work in #432 along its Section A/B/C/D boundary so the foundation can land cleanly before the larger install refactor follows.

### What's in this PR

**Section A — Config**
- `Config::enable_global_virtual_store` (default `true`, matching upstream's [`config/reader/src/index.ts:392-394`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/index.ts#L392-L394))
- `Config::global_virtual_store_dir`
- `pnpm-workspace.yaml` deserialization for `enableGlobalVirtualStore` / `globalVirtualStoreDir`
- `Config::apply_global_virtual_store_derivation` mirroring upstream's [`extendInstallOptions.ts:343-355`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/extendInstallOptions.ts#L343-L355). **Not** called from `Config::current` yet — the install layer in the follow-up will invoke it explicitly so this PR doesn't silently redirect installs to `<store_dir>/links` before the rest of the layout math is wired.

**Section C (foundation only) — Project registry helpers**
- `StoreDir::links()`, `StoreDir::projects()`, `StoreDir::root()`
- `pacquet_store_dir::register_project` (port of upstream's [`registerProject`](https://github.com/pnpm/pnpm/blob/94240bc046/store/controller/src/storeController/projectRegistry.ts))
- `pacquet_store_dir::create_short_hash` (port of [`createShortHash`](https://github.com/pnpm/pnpm/blob/94240bc046/crypto/hash/src/index.ts)) — pinned test vector against `printf pacquet | shasum -a 256 | head -c 32`

**Section B (foundation only) — Graph-hasher + layout helpers**
- `pacquet_graph_hasher::calc_graph_node_hash` (port of [`calcGraphNodeHash`](https://github.com/pnpm/pnpm/blob/94240bc046/deps/graph-hasher/src/index.ts#L122-L146))
- `pacquet_graph_hasher::format_global_virtual_store_path` (port of [`formatGlobalVirtualStorePath`](https://github.com/pnpm/pnpm/blob/94240bc046/deps/graph-hasher/src/index.ts#L155-L160))
- `pacquet_package_manager::VirtualStoreLayout` precomputes each snapshot's slot directory on disk and hides the GVS-vs-legacy path divergence behind one `slot_dir(&PackageKey) -> PathBuf` lookup

### What's deferred to the follow-up PR

- Wiring `VirtualStoreLayout` through `CreateVirtualStore`, `CreateVirtualDirBySnapshot`, `SymlinkDirectDependencies`, `InstallPackageBySnapshot`, `BuildModules`, `link_bins`, and `create_symlink_layout`
- Calling `register_project` from `Install::run`
- Calling `apply_global_virtual_store_derivation` from `Config::current`
- Updating ~20 existing test setups to opt out of the (now-default) GVS mode and porting `installing/deps-installer/test/install/globalVirtualStore.ts`

### No behavior change at install time

Every new field, helper, and module is unreachable from the existing install path. `just ready` is green; new unit tests added for each helper:
- `pacquet-config`: GVS derivation truth table (default re-points / disabled / user-pinned)
- `pacquet-store-dir`: short-hash pinned vector + register idempotency + isSubdir guard
- `pacquet-graph-hasher`: GVS hash determinism + engine sensitivity + child sensitivity + unscoped `@/` prefix
- `pacquet-package-manager`: `VirtualStoreLayout` slot_dir under GVS-on and GVS-off

Closes part of #432; #432 stays open until the install-pipeline wiring + test ports land.
2026-05-13 13:30:25 +02:00
Zoltan Kochan
fe8cf4d0ee feat(lockfile,store-dir): gitHosted on TarballResolution + pickStoreIndexKey (#436 §A) (#440)
Foundation for git-hosted package install (#436 Section A). Three small,
self-contained changes:

- **`TarballResolution.gitHosted: Option<bool>`** in `crates/lockfile`.
  Recent pnpm lockfiles ship this field on tarballs sourced from a git
  host. Without it, pacquet's `deny_unknown_fields` strictness rejected
  real lockfiles before the install dispatcher ran — a silent correctness
  gap independent of the rest of #436. Mirrors upstream's
  [`TarballResolution.gitHosted`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/types/src/index.ts#L88-L107).

- **Back-fill on read.** When `gitHosted` is absent but the tarball URL
  prefix-matches a known git host (`codeload.github.com`, `gitlab.com`,
  `bitbucket.org`) with a `tar.gz` substring, set it to `true` during
  deserialize. Mirrors upstream's
  [`enrichGitHostedFlag`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/fs/src/lockfileFormatConverters.ts#L158-L168)
  so every downstream reader can rely on the typed field instead of
  matching URL prefixes at each site.

- **`git_hosted_store_index_key` + `pick_store_index_key`** helpers in
  `pacquet-store-dir`. Git-hosted cache content depends on whether the
  build ran during fetch, so the cache key carries a `built` / `not-built`
  dimension that the integrity-only key would collapse. Mirrors
  upstream's
  [`gitHostedStoreIndexKey` / `pickStoreIndexKey`](https://github.com/pnpm/pnpm/blob/94240bc046/store/index/src/index.ts#L60-L94).
  No call sites switch yet — the dispatcher continues to error with
  `UnsupportedResolution { resolution_kind: "git" }` until the git fetcher
  lands.
2026-05-13 12:34:38 +02:00
Zoltan Kochan
c4fce797b6 feat(config): POSIX root auto-detect for unsafePerm (#397 §14 follow-up) (#430)
* feat(config): POSIX root auto-detect for unsafePerm (#397 §14)

Completes the deferred half of #397 item 14. Adds `libc` to
workspace deps (Unix-only at the crate level via
`[target.'cfg(unix)'.dependencies]`) and implements
`default_unsafe_perm()` mirroring upstream's
[`extendBuildOptions.ts:83-86`](https://github.com/pnpm/pnpm/blob/94240bc046/building/after-install/src/extendBuildOptions.ts#L83-L86):

```ts
unsafePerm: process.platform === 'win32' ||
  process.platform === 'cygwin' ||
  !process.setgid ||
  process.getuid?.() !== 0
```

Pacquet equivalent:

- Windows → `true` (cfg!(windows) short-circuits; covers Cygwin
  too because Cygwin builds target windows-gnu, so the
  `'cygwin'` branch from upstream is implicitly handled).
- POSIX, running as root → `false` (drop privileges).
- POSIX, non-root → `true`.

The `!process.setgid` branch upstream is a Node-version
compatibility check for older Node where `setgid` doesn't exist;
it doesn't translate to Rust (libc's `setgid` is always available
on POSIX hosts where libc compiles).

`Config.unsafe_perm`'s default switches from a hard-coded `true`
to `default_unsafe_perm()`. Yaml override and the Windows
force-override in `WorkspaceSettings::apply_to` stay as they were.
`is_unsafe_perm_posix(uid)` is exposed for tests so the per-uid
logic can be exercised without root privileges.

Tests: new `is_unsafe_perm_posix_truth_table` (root vs non-root
uid table); `default_unsafe_perm_on_posix_matches_runtime_uid`
(POSIX-gated); `default_unsafe_perm_on_windows_is_always_true`
(Windows-gated). The existing `parses_unsafe_perm_from_yaml_and_applies`
drops the explicit "default is true" assertion since the default
now depends on the runtime uid — the test's purpose (yaml-`false`
wins on POSIX) is preserved.

* fix(config): address PR #430 review (Cygwin + non-POSIX + unsafe fn)

Four review-flagged issues from PR #430:

1. **Cygwin handling.** Rust's `x86_64-pc-cygwin` target emits
   `target_os = "cygwin"` with `cfg!(unix)` set and `cfg!(windows)`
   *unset*. The previous `cfg!(windows)` check fell through to the
   uid logic on Cygwin, which would flip to `false` when running
   as root — diverging from upstream's unconditional-true Cygwin
   branch. Added explicit `target_os = "cygwin"` matching alongside
   `windows`.

2. **Non-Unix, non-Windows targets.** The previous `posix_getuid`
   stub returned `0` on non-Unix targets, which made
   `default_unsafe_perm` return `false` (root-treating). Replaced
   with three cfg-gated `platform_unsafe_perm_default` overloads:
   Windows/Cygwin → `true`; POSIX-excluding-Cygwin → uid-based;
   anything else (`wasm32-*`, `redox`, etc.) → `true`.

3. **Over-marked `unsafe fn posix_getuid`.** Copilot was right
   that the safety comment described no per-call invariants, only
   the FFI boundary. Switched to a safe `fn` with the `unsafe`
   block contained inside, eliminating the unnecessary `unsafe`
   propagation to callers.

4. **Stale doc claim about Cygwin.** The doc said Cygwin builds
   target `windows-gnu` and that `cfg!(windows)` covers it.
   Neither is correct — `x86_64-pc-cygwin` is a distinct target.
   Updated the doc to describe the explicit `target_os = "cygwin"`
   branch with a link to rustc's platform-support page.

Cargo dep gate also narrowed: `[target.'cfg(all(unix, not(target_os
= "cygwin")))'.dependencies]` so libc isn't built on Cygwin (where
the POSIX branch isn't taken). The `default_unsafe_perm_on_posix_matches_runtime_uid`
test was correspondingly narrowed to the same cfg, and a new
`default_unsafe_perm_on_cygwin_is_always_true` test pins the
Cygwin short-circuit.
2026-05-13 01:49:32 +02:00
Zoltan Kochan
2291bc6c2e feat: build concurrency, unsafe-perm, scriptsPrependNodePath (#397 items 12, 14, 15) (#429)
Bundles the three remaining moderate/minor parity items from #397 into one PR. Each adds a `pnpm-workspace.yaml` setting, threads it through `Config`, and applies it to `BuildModules` so a pnpm yaml that exercises these knobs produces the same install behavior in pacquet.

- **Item 12 — `childConcurrency`** ([upstream `getWorkspaceConcurrency`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/reader/src/concurrency.ts#L25-L34); [`runGroups(...)`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/during-install/src/index.ts#L124)). Adds `Config.child_concurrency: u32` with the upstream-matching default `min(4, availableParallelism())` and the negative-offset semantics (`n < 0` → `max(1, parallelism - |n|)`). `BuildModules::run` now dispatches each chunk's members across a bounded rayon thread pool via `par_iter().try_for_each` — chunks themselves remain sequential to preserve topological order. `ignored_builds` and `deps_state_cache` are wrapped in `Mutex` so the recursive memo and the dedup set survive concurrent chunk-member access.
- **Item 14 — `unsafePerm`**. Adds `Config.unsafe_perm: bool` (default `true`) and threads it through to `RunPostinstallHooks`. When `false`, the executor sets `TMPDIR=node_modules/.tmp` for the spawn. The uid/gid drop side is a no-op upstream too because pnpm's [`@pnpm/npm-lifecycle`](https://github.com/pnpm/npm-lifecycle/blob/d2d8e790/index.js#L204-L220) never populates `opts.user` / `opts.group`. The POSIX auto-detect (`getuid() === 0 && setgid → unsafePerm = false`) needs `libc`, which isn't in `[workspace.dependencies]` yet — for now, root-run CI must set `unsafePerm: false` in yaml explicitly. Windows is force-overridden to `true` in `WorkspaceSettings::apply_to`, matching upstream's `process.platform === 'win32'` gate.
- **Item 15 — `scriptsPrependNodePath`** ([`Config.scriptsPrependNodePath`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/reader/src/Config.ts#L108)). Adds a tri-state `ScriptsPrependNodePath` enum in `pacquet-config` (custom serde `Deserialize` for the `boolean | "warn-only"` yaml shape) and converts to `pacquet_executor::ScriptsPrependNodePath` at the `BuildModules` call site so the executor crate stays free of serde wiring. Default is `Never` to match upstream's [`StrictBuildOptions.scriptsPrependNodePath: false`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/after-install/src/extendBuildOptions.ts#L78).
- **Item 16 — `getSubgraphToBuild` filter trimming**. Already implemented in pacquet's `get_subgraph_to_build` via the `child_should_be_built || needs_build || has_patch` gate. Confirmed during this slice; no code change needed.

Why one PR: items 12 / 14 / 15 are three independent yaml-driven config knobs, all small. Bundling keeps the cross-crate signature churn from happening three times (`BuildModules`'s field list, `install_frozen_lockfile`'s call site, and the test fixtures all touch each one), and the upstream sources for the three settings live next to each other so the porting context is shared.
2026-05-13 01:07:15 +02:00
Zoltan Kochan
f9b14b15ec feat(package-manager): version unions in allowBuilds keys (#397 item 5 completion) (#428)
Completes pacquet#397 item 5. Slice A+B's PR #425 moved
`AllowBuildPolicy` off `package.json` onto `pnpm-workspace.yaml`;
this commit ports the second half of upstream's matcher — the
`expandPackageVersionSpecs` step that lets users write keys like
`foo@1.0.0 || 2.0.0` in their `allowBuilds` map.

## What this adds

New `pacquet_package_manager::version_policy` module porting
[`config/version-policy/src/index.ts`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/version-policy/src/index.ts)
at commit `b4f8f47ac2`. `expand_package_version_specs` parses each
`allowBuilds` key into one or more `name` / `name@version` literal
strings:

- `foo` → `{"foo"}`
- `foo@1.0.0` → `{"foo@1.0.0"}`
- `foo@1.0.0 || 2.0.0` → `{"foo@1.0.0", "foo@2.0.0"}`
- `@scope/foo@1.0.0` → `{"@scope/foo@1.0.0"}`

Two error codes mirror upstream:

- `ERR_PNPM_INVALID_VERSION_UNION` when a `||` member isn't valid
  semver
- `ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION` when a `*` wildcard in
  the name is combined with a version part

Whitespace around `||` and within each version is trimmed before
parsing, matching Node-semver's `valid()`.

## What this does NOT add

Wildcards in the name (`is-*`, `@scope/*`) are accepted by the
parser and land in the expanded set as literal strings, but
`HashSet::contains` lookups mean they never match real package
names. Mirrors upstream's `'should not allow patterns in allowBuilds'`
test at [`building/policy/test/index.ts:28-34`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/policy/test/index.ts#L28-L34)
— the original #397 audit incorrectly claimed `@scope/*` should
work in `allowBuilds`; it doesn't, neither upstream nor here.
`createPackageVersionPolicy` (which DOES support wildcards via
`Matcher`) is a separate upstream function used by
`minimumReleaseAgeExclude` / `dlx` — pacquet doesn't have those
features yet.

## AllowBuildPolicy refactor

`AllowBuildPolicy`'s internal storage changes from
`HashMap<String, bool>` to two `HashSet<String>` (`expanded_allowed`
and `expanded_disallowed`), populated through
`expand_package_version_specs`. The `check` function checks
`disallowed` before `allowed`, both against bare `name` and
`name@version`, mirroring upstream's order at
[`building/policy/src/index.ts:35-44`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/policy/src/index.ts#L35-L44).

This fixes a pre-existing pacquet divergence: the old matcher
checked exact-version first, then bare name. With upstream's
order, a bare-name disallow now correctly wins over an
exact-version allow. New
`disallow_bare_name_wins_over_allow_exact_version` test pins the
behavior; the old `exact_version_takes_precedence` test was
removed (it asserted the divergent behavior).

`AllowBuildPolicy::new` now takes the expanded sets directly
(pure constructor — no IO). `AllowBuildPolicy::from_config`
returns `Result<Self, VersionPolicyError>` so spec-parse failures
surface at install time instead of being silently dropped. New
`InstallFrozenLockfileError::VersionPolicy` variant propagates the
error.

## Tests

- 12 new tests in `version_policy::tests` covering: bare name,
  exact version, version union, scoped names, whitespace trimming
  in unions, name-with-wildcard-alone (literal), invalid version
  union (error), mixed valid/invalid (error), wildcard-with-version
  (error), empty input, duplicate collapse.
- Ports of upstream's `building/policy/test/index.ts` cases:
  `allow_via_version_union` (version-union allows),
  `wildcard_name_in_allow_builds_does_not_match_real_package`
  (wildcards inert), `disallow_bare_name_wins_over_allow_exact_version`
  (matcher order), `disallow_exact_version_with_allow_bare_name`
  (converse).
- `from_config` error-propagation tests for both `VersionPolicyError`
  variants.

598 tests pass; `just ready`, `just dylint`, `cargo doc -D warnings
--document-private-items`, `taplo format --check` all clean.
2026-05-12 23:09:40 +02:00
Zoltan Kochan
13082fc1bf feat: apply patchedDependencies before postinstall (#397 item 9) (#427)
Completes pnpm/pacquet#397 item 9 (`patchedDependencies` support). Patches now actually land on disk: pacquet applies configured patches to extracted package directories before postinstall hooks run, includes patched-only packages in the build trigger, and surfaces the four upstream diagnostic codes.

Slices A + B (PR #425, merged) landed the foundation, Config plumbing, and the side-effects-cache key wiring. This PR wires the rest.

## Pure-Rust patch applier

`pacquet_patching::apply_patch_to_dir` ports upstream's [`applyPatchToDir`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/patching/apply-patch/src/index.ts) using the [`diffy`](https://crates.io/crates/diffy) crate (MIT OR Apache-2.0, pure Rust, no subprocess). Upstream's own comment notes that `patch` is unavailable on Windows and `git apply` is awkward inside an existing git repo — pnpm vendored `@pnpm/patch-package` for that reason; pacquet sidesteps it the same way with an in-process applier.

Supported `FileOperation`s: `Modify`, `Create`, `Delete`. `Rename` and `Copy` fall through to `ERR_PNPM_PATCH_FAILED` with a descriptive message — they don't appear in `patch-package`-style patches in practice.

## Build pipeline

- `build_sequence::get_subgraph_to_build` now considers `patch.is_some()` alongside `requires_build`. A patched-only package becomes a build candidate the same way upstream's filter at [`buildSequence.ts:47,60`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/during-install/src/buildSequence.ts#L40-L67) treats them.
- `BuildModules::run` per-snapshot loop refactored to mirror upstream's flow:
  - Inner build trigger becomes `requires_build || patch.is_some()`.
  - `allowBuilds` check only gates scripts (upstream's `if (node.requiresBuild)` at [`during-install/src/index.ts:88-101`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/during-install/src/index.ts#L82-L106)). Disallow / ignored sets `should_run_scripts = false` rather than `continue` — patches still apply.
  - `cache_key`'s `include_dep_graph_hash` is now `should_run_scripts` (was unconditionally `true`), so a patched-only snapshot's cache key omits the deps-hash, matching upstream's `includeDepGraphHash: hasSideEffects` at [line 202](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/during-install/src/index.ts#L199-L204).
  - Patch application happens before `run_postinstall_hooks`, mirroring [`during-install/src/index.ts:171-178`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/during-install/src/index.ts#L168-L191).
  - Cache-write gate becomes `(is_patched || has_side_effects) && side_effects_cache_write` (was just `side_effects_cache_write`), matching upstream's `(isPatched || hasSideEffects)` at line 199.

## Diagnostics

Four upstream codes surface via `BuildModulesError`:

- `ERR_PNPM_PATCH_NOT_FOUND` — patch file missing on disk
- `ERR_PNPM_INVALID_PATCH` — unified-diff parse error
- `ERR_PNPM_PATCH_FAILED` — hunk wouldn't apply, target missing, IO error on a target path
- `ERR_PNPM_PATCH_FILE_PATH_MISSING` — resolved patch entry has a hash but no path (lockfile-only shape with no live config); mirrors [`during-install/src/index.ts:172-176`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/during-install/src/index.ts#L172-L176)

## Dependency

New workspace dep: `diffy = "0.5.0"`. MIT OR Apache-2.0, both in the deny.toml allow-list. `cargo deny check licenses` ok.
2026-05-12 21:54:01 +02:00
Zoltan Kochan
f2d41415af feat: patchedDependencies foundation + cache key + AllowBuildPolicy from Config (#397 items 9 A+B, 5) (#425)
Combined slices A and B of pnpm/pacquet#397 item 9 (`patchedDependencies` support), plus the related #397 item 5 fix (`AllowBuildPolicy` moves off `package.json`).

This PR adds the foundation (`pacquet-patching` crate) and threads patches as far as the side-effects-cache key. The build-trigger update and the actual patch application are deferred to slice C — they only make sense paired together.

## Foundation (`pacquet-patching` crate)

Ports `@pnpm/patching.types` + `@pnpm/patching.config` + `calcPatchHashes` at pnpm SHA [`b4f8f47ac2`](https://github.com/pnpm/pnpm/tree/b4f8f47ac2):

- **Types** (`types.rs`) — `PatchInfo`, `ExtendedPatchInfo`, `PatchGroupRangeItem`, `PatchGroup`, `PatchGroupRecord` mirroring [`patching/types/src/index.ts`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/patching/types/src/index.ts).
- **Key parser** (`key.rs`) — minimal port of [`dp.parse`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/deps/path/src/index.ts#L120-L168).
- **Grouping** (`group.rs`) — `group_patched_dependencies` mirroring [`groupPatchedDependencies.ts`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/patching/config/src/groupPatchedDependencies.ts), with `ERR_PNPM_PATCH_NON_SEMVER_RANGE`.
- **Matcher** (`get_patch_info.rs`) — `get_patch_info` (exact → unique-range → wildcard) mirroring [`getPatchInfo.ts`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/patching/config/src/getPatchInfo.ts), with `ERR_PNPM_PATCH_KEY_CONFLICT`.
- **Verify** (`verify.rs`) — `all_patch_keys` + `verify_patches` with `ERR_PNPM_UNUSED_PATCH`.
- **Hashing** (`hash.rs`) — SHA-256 hex with CRLF→LF normalization (matters for cross-platform stability of the cache key).
- **Resolve helper** (`resolve.rs`) — `resolve_and_group(workspace_dir, raw_map)` resolves relative patch paths against the workspace dir, hashes, and groups.

## Config plumbing (workspace yaml is the source of truth)

pnpm v11 reads install settings from `pnpm-workspace.yaml`, not `package.json`. This PR matches that for `patchedDependencies` and (as the #397 item 5 fix) `allowBuilds` / `dangerouslyAllowAllBuilds` too. The misleadingly-named upstream [`getOptionsFromRootManifest.ts`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/reader/src/getOptionsFromRootManifest.ts) is called at [`config/reader/src/index.ts:814`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/reader/src/index.ts#L814) with the *workspace* manifest.

- `Config.workspace_dir: Option<PathBuf>` — recorded by `WorkspaceSettings::apply_to` so install-time code can resolve patch file paths against the same dir upstream uses.
- `Config.patched_dependencies: Option<BTreeMap<String, String>>` — raw map. Hashing + resolution deferred to `Config::resolved_patched_dependencies` so `apply_to` stays infallible.
- `Config.allow_builds: HashMap<String, bool>` and `Config.dangerously_allow_all_builds: bool` — replace `AllowBuildPolicy::from_manifest`'s `package.json` reads.
- `WorkspaceSettings` deserializes the matching three yaml keys (`patchedDependencies`, `allowBuilds`, `dangerouslyAllowAllBuilds`); `apply_to` pushes them.

## Install-pipeline threading

- `InstallFrozenLockfile::run` calls `config.resolved_patched_dependencies()` once per install, then for each snapshot calls `pacquet_patching::get_patch_info(&groups, name, version)` and collects matches into `HashMap<PackageKey, ExtendedPatchInfo>` keyed by the peer-stripped key. Mirrors upstream's per-resolution lookup at [`resolveDependencies.ts:1482`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/pkg-manager/resolve-dependencies/src/resolveDependencies.ts#L1482), adapted for pacquet's lockfile-driven flow.
- `BuildModules` gains a `patches: Option<&HashMap<PackageKey, ExtendedPatchInfo>>` field. The `calc_dep_state` call at the existing `build_modules.rs:326` placeholder now passes `patch_file_hash: patch.map(|p| p.hash.as_str())`, matching upstream's [`patchFileHash: depNode.patch?.hash`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/during-install/src/index.ts#L201).

## `AllowBuildPolicy` (#397 item 5)

- Drop `AllowBuildPolicy::from_manifest` (the `package.json` reader was a workaround until Config plumbing landed).
- Add `AllowBuildPolicy::from_config(&Config)` consuming the new yaml-sourced fields.
- `install_frozen_lockfile.rs:156` switches over.

## What's NOT in this PR

Tracked for slice C:

- **Build trigger update** (`requires_build || patch.is_some()`) and **patch application** to extracted package directories before postinstall hooks. These belong together — wiring the trigger in isolation produces an incoherent state (the cache key advertises a patch that isn't on disk).
- Decision on the patch applier (Rust crate vs `git apply` subprocess) will be raised when slice C opens.
- Diagnostics: `ERR_PNPM_PATCH_FILE_PATH_MISSING` / `PATCH_NOT_FOUND` / `INVALID_PATCH` / `PATCH_FAILED`.
- End-to-end test parity with upstream's [`simple-with-patch`](https://github.com/pnpm/pnpm/tree/b4f8f47ac2/pkg-manager/headless/test/fixtures/simple-with-patch) fixture.
2026-05-12 20:31:45 +02:00
Zoltan Kochan
f80275abbd docs(agents): require confirming pnpm/pnpm main is up to date before reading (#426)
Local clones drift, so porting from a stale checkout silently bakes in
old upstream behavior. Tell agents to fetch (or use GitHub's `/blob/main`
view) before consulting upstream, and clarify this doesn't conflict with
the permalink-when-citing rule.
2026-05-12 19:18:59 +02:00
Zoltan Kochan
57174b869f feat: side-effects cache WRITE path (#421 slice B) (#424)
## Summary

Implements slice (B) of #421 — the WRITE path for pnpm's side-effects cache. With this PR, a successful postinstall now re-CAFSes the built package directory, diffs against the pristine `PackageFilesIndex.files` row, and mutates the row's `side_effects` map so a future install (pacquet *or* pnpm) can skip the rebuild via the READ path landed in #423.

User-visible behavior: lifecycle scripts that produce build artifacts (`node-gyp` outputs, generated source files, etc.) get cached the first time the install runs; subsequent installs against the same lockfile + Node major skip the script and reuse the cached overlay.

## What lands

- **`pacquet_store_dir::add_files_from_dir`** — directory walker that re-CAFSes a post-build package directory. Mirrors pnpm/pnpm@`7e3145f9fc`:`store/cafs/src/addFilesFromDir.ts:16-194` + `worker/src/start.ts:312-383`. Containment: `dunce::canonicalize` each symlink target, drop if out of root; read/recurse via the resolved canonical path so retargeting a symlink between containment check and read can't smuggle out-of-root data into CAFS. Skips top-level `node_modules` (matches upstream `includeNodeModules: false`). Cycle-safe via a recursion-stack `visited: HashSet<PathBuf>` (entries inserted on descend, removed on return).
- **`pacquet_store_dir::upload` + `calculate_diff`** — `upload(store_dir, built_pkg_location, files_index_file, cache_key, writer)` walks the package via `add_files_from_dir` and queues a `WriteMsg::SideEffectsUpload { key, cache_key, current_files }` to the writer task. The actual R/M/W of the existing `PackageFilesIndex` row happens inside the writer task (see below), so concurrent uploads to the same row stay commutative.
- **`StoreIndexWriter` now drives a `WriteMsg` enum** (`Replace` vs `SideEffectsUpload`). The batch loop coalesces per-key updates into one `PackageFilesIndex` value before flushing — multiple side-effects uploads for the same row apply in arrival order against a single in-memory state instead of racing against a stale read on a separate readonly handle. The R/M/W (load existing row → check `algo == HASH_ALGORITHM = "sha512"` → `calculate_diff` → insert into `side_effects` map) happens inside the writer's transaction. Loading short-circuits with a `debug!` / `warn!` when the base row is missing or unreadable, matching upstream's `if (!existingFilesIndex) return` / `ALGO_MISMATCH` bail-outs at `pnpm/pnpm@7e3145f9fc:worker/src/start.ts:342-371`.
- **`Config.side_effects_cache_readonly: bool`** (default `false`) — mirrors pnpm's `sideEffectsCacheReadonly`. Two derived helpers consolidate the gate semantics so precedence stays single-sourced:
  - `Config::side_effects_cache_read()` = `cache || readonly`
  - `Config::side_effects_cache_write()` = `cache && !readonly`
- **`BuildModules` WRITE-path call site** — after `run_postinstall_hooks` returns `Ok`, if `side_effects_cache_write` is on and `StoreIndexWriter` + `StoreDir` are both threaded in, calls `pacquet_store_dir::upload(...)` with the snapshot's `store_index_key(integrity, pkg_id)` and the `calc_dep_state` cache key. All upload errors are swallowed with `tracing::warn!`, mirroring upstream's `try { upload } catch { logger.warn }` at `pnpm/pnpm@7e3145f9fc:building/during-install/src/index.ts:208-215`.
- **`build_deps_subgraph`** — the cache-hashing dep graph is now bounded to the forward closure of `requires_build` snapshots. Pure-JS installs feed in an empty root iterator and the function returns immediately; installs with buildable deps walk only what `calc_dep_state` will actually traverse. Upstream's `lockfileToDepGraph` builds the full graph because its consumers extend beyond cache hashing; pacquet's graph is consumed only by `calc_dep_state` today, so the closure-bounded walk produces byte-identical cache keys with strictly less work. Restored the cold-install benchmark to parity with `main`.
- **`StoreIndexWriter` hoisted up to `InstallFrozenLockfile::run`** — previously spawned + drained inside `CreateVirtualStore::run`; now it spans both the prefetch/download phase and the build phase so the WRITE-path upload can queue through it. The final batch flushes after `BuildModules::run` returns.
- **Byte-stable msgpack encoding** — `encode_package_files_index` now iterates `files`, `side_effects`, and `SideEffectsDiff.added` in sorted-key order so two pacquet writes of the same logical row produce identical bytes. Was prompted by a review on PR #424 pointing out that `HashMap` iteration randomization would otherwise make every row look "changed" on byte-diff even when content was identical.
- **`cache_key` refactor in `BuildModules`** — `calc_dep_state` now fires once per snapshot (before the gate check) so both the READ gate and the new WRITE site read the same value.

## Tests

- **`add_files_from_dir::tests`** (9 cases): top-level files, nested forward-slash paths, exec-bit propagation, top-level-vs-nested `node_modules`, symlink-out-of-root drop, symlink-in-root follow, directory-cycle termination, missing-root error.
- **`upload::tests`** (6 cases): identical / added-only / deleted-only / digest change / mode change / mixed — covers every branch of upstream's `calculateDiff`.
- **`deps_graph::tests`** (new for slice B): empty-roots → empty subgraph; forward-closure inclusion + exclusion; cycle termination.
- **`workspace_yaml::tests`**: `sideEffectsCacheReadonly` parses + applies; the 4-state truth table for `side_effects_cache_read()` / `side_effects_cache_write()`.
- **`build_modules::tests`** (new):
  - `write_path_populates_side_effects_row` — full WRITE-path: pre-seed a base row, run a postinstall that creates `generated.txt`, drain the writer task, assert `side_effects[cache_key].added` contains `generated.txt` and NOT pristine `index.js`. Mirrors the spirit of upstream's `'a postinstall script does not modify the original sources added to the store'` at `pnpm/pnpm@7e3145f9fc:sideEffects.ts:189-223`.
  - `write_path_disabled_skips_upload` — same scenario with `side_effects_cache_write: false` → row's `side_effects` stays `None`. Counterpart of upstream's gate on `sideEffectsCacheWrite`.
  - `upload_error_does_not_interrupt_install` — postinstall produces a 0-permission file in the package dir; the walker fails on `fs::read` with `EACCES`, surfacing as `UploadError::AddFilesFromDir(_)` which `BuildModules` swallows with `tracing::warn!`. Asserts (a) the postinstall-created `generated.txt` is on disk and (b) the pre-seeded base row's `side_effects` stays `None`. Mirrors `'uploading errors do not interrupt installation'` at `pnpm/pnpm@7e3145f9fc:sideEffects.ts:166-187`.

## Out of scope

- **Patch-file hash in cache key** — depends on `patchedDependencies` (#397 item 9). Today's WRITE side stamps `patch_file_hash: None`; same as the existing READ side.
- **`requires_build` recompute** in the R/M/W path. Upstream recomputes from `(manifest, filesMap)` when the existing row's field is `None`; pacquet leaves the field as-is (cold-batch downloads already populate it with a real value).
- **GVS / hoisted-node-linker** variants of the upstream tests.
2026-05-12 18:32:16 +02:00
Zoltan Kochan
03bb90a3fc feat(package-manager): is_built gate for the side-effects cache READ path (#421 slice A) (#423)
## Summary

Implements slice (A) of #421 — the READ-path gate that consumes the foundation merged in #422. With a matching side-effects-cache entry on disk (typically seeded by a prior `pnpm install`), `BuildModules` now skips lifecycle scripts for that snapshot, mirroring upstream's [`!node.isBuilt` filter](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/during-install/src/index.ts#L73-L77).

User-visible behavior: a warm `pacquet install` after `pnpm install` no longer rebuilds packages whose post-build state is already in the store.

## What lands

- **`pacquet_graph_hasher::detect_node_major()`** — spawns `node --version` once at install start, parses the major version. The parse helper is factored out so the rule is unit-tested without a child process. Missing-node returns `None` and the gate falls through to "rebuild" (safe).
- **`DepsGraphNode` flipped to own its strings** — was borrowed `&'a str`, now `String`. Lets callers building the graph from a lockfile skip a separate-arena lifetime story. No external consumers besides in-crate tests yet, so this is internal-only.
- **`PrefetchResult.side_effects_maps`** surfaces the per-row `cache_key → FilesMap` overlay that the prefetch loop previously dropped (since #422 added it to `VerifyResult`). `Arc`-wrapped for the same cold-batch cheap-clone reason `cas_paths` is.
- **`CreateVirtualStore` returns a new `CreateVirtualStoreOutput`** struct: `package_manifests` (existing) plus `side_effects_maps_by_snapshot: HashMap<PackageKey, Arc<…>>` (new). Peer-variants of the same package share one `Arc<…>` because the store-index row is keyed peer-stripped.
- **New `pacquet_package_manager::deps_graph::build_deps_graph` adapter** — walks `snapshots` + `packages` and builds a `DepsGraph<PackageKey>` for the hasher. `full_pkg_id` derives from `<pkg_id>:<integrity>` for registry / tarball resolutions, or `<pkg_id>:<hashObject(resolution)>` for git / directory / integrity-less tarball, matching upstream's [`createFullPkgId`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/deps/graph-hasher/src/index.ts#L263-L292).
- **`BuildModules` is_built gate** — after the policy gate, before extraction, the build loop computes `calc_dep_state` per snapshot (with `include_dep_graph_hash: true` mirroring upstream's `hasSideEffects`), looks the cache key up in the threaded-in `side_effects_maps`, and `continue`s on hit. The `Config.side_effects_cache: false` flag short-circuits the lookup entirely.
- **`InstallFrozenLockfile`** detects the engine name once and threads it (plus the prefetch's side-effects map and the config flag) into `BuildModules`.

## Tests

- `using_side_effects_cache_skips_rebuild` ports the upstream `'using side effects cache'` case from [`installing/deps-installer/test/install/sideEffects.ts:79`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/installing/deps-installer/test/install/sideEffects.ts#L79-L131). Hand-builds the `side_effects_maps_by_snapshot` entry with the exact cache key `BuildModules` will compute, asserts no `pnpm:lifecycle` event fires for `@pnpm.e2e/failing-postinstall`'s postinstall (i.e. the build was skipped — if the gate were broken the script would have run and the install would have failed with `LifecycleScript(_)`).
- `side_effects_cache_disabled_bypasses_the_gate` (negative pair): same overlay shape with `side_effects_cache: false`, expects the build to run and the install to fail with the underlying `exit 1` from the postinstall.
- `deps_graph::tests`: registry-resolution uses integrity verbatim, dependencies + optionalDependencies fold into children, snapshot without metadata is skipped.

The upstream test runs the install twice — first to populate the cache via the WRITE path, then to consume it. Pacquet doesn't have a WRITE path yet (#421 slice B), so the port hand-crafts the same in-memory state directly. The behavior under test is identical.

## Out of scope

- **WRITE path** — pacquet seeding the cache itself (re-CAFS post-build, calc diff, mutate index row). Tracked as slice (B) in #421.
- **Patch-file hash in the cache key** — depends on `patchedDependencies` (#397 item 9).
2026-05-12 15:29:59 +02:00
Zoltan Kochan
e164bdbf36 feat: foundation for the side-effects cache (#397 item #10, part 1) (#422)
Foundation for porting pnpm's side-effects cache (item #10 of #397). This PR lands the three pieces that the actual cache read/write paths depend on:

- **New `pacquet-graph-hasher` crate** porting pnpm's `@pnpm/crypto.object-hasher` (`hashObject` / `hashObjectWithoutSorting`) plus `@pnpm/deps.graph-hasher` (`calcDepState` / `calcDepGraphHash`) and `ENGINE_NAME`. Byte-for-byte parity with the JS [`object-hash@3.0.0`](https://github.com/puleos/object-hash/blob/v3.0.0/index.js) bytestream format is load-bearing — the cache key is persisted on disk and shared with pnpm. The headline parity test pins `hashObject({b:1,a:2}) == "48AVoXIXcTKcnHt8qVKp5vNw4gyOB5VfztHwtYBRcAQ="` against the upstream fixture in [`crypto/object-hasher/test/index.ts:6`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/crypto/object-hasher/test/index.ts#L6).
- **`VerifyResult.side_effects_maps`** in `pacquet-store-dir`. The verify path (`check_pkg_files_integrity` and `build_file_maps_from_index`) used to drop `PackageFilesIndex.side_effects` after extraction. It now applies the `added`/`deleted` overlay per cache key and surfaces a `HashMap<cache_key, FilesMap>` — the same shape pnpm uses for its `PackageFilesResponse.sideEffectsMaps`. Mirrors [`applySideEffectsDiffWithMaps`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/store/create-cafs-store/src/index.ts#L103-L121).
- **`Config.side_effects_cache`** config knob (default `true`, matching pnpm). Surfaced on the wire **only** — no consumer yet. Wires up cleanly once the build-phase gate lands in #421, with no config migration needed for downstream callers.

## Scoped narrowly

This is **only the foundation**. There is no `is_built` field, no `BuildModules` skip gate, and no end-to-end test of the rebuild-skip behavior. Those land in a separate follow-up tracked in #421, which also covers the WRITE path (pacquet seeding the cache itself).

The natural slice for review: graph-hasher unblocks both READ and WRITE, the verify-path surfacing is the only intrusive cross-crate change, and the config knob is forward-looking.

### Hashing scope, explicitly

The `pacquet-graph-hasher` port implements only the type arms pacquet actually feeds into `hashObject` for the cache-key path: strings, objects, numbers, booleans, null, and arrays in their ordered form. `Set` / `Map` / `Date` / `Buffer` / `Array` unordered-permutation arms from [upstream `object-hash`](https://github.com/puleos/object-hash/blob/v3.0.0/index.js#L257-L389) are unimplemented — no caller in pacquet's tree feeds them in today, and the test fixtures upstream uses (`hashObject({ b: new Set([…]), a: [...] })`) hash deterministically across orderings only because they're testing the library, not exercising a real call path. Adding them can land alongside a future caller that needs them.
2026-05-12 14:18:06 +02:00
Zoltan Kochan
582ef43b6c refactor(config): rename pacquet-npmrc → pacquet-config and drop ini wiring (#420)
## Summary

- pnpm 11 reads project-structural settings (storeDir, hoist, nodeLinker, fetchRetries, …) from `pnpm-workspace.yaml` and limits `.npmrc` to auth/registry/network keys. The crate that holds pacquet's resolved runtime config was still named `pacquet-npmrc` with a `Npmrc` type and carried `serde_ini` deserializer wiring on every field, which no longer reflected how the config is actually loaded on this branch.
- Two-commit refactor: rename the crate / type / imports, then drop the dead serde_ini machinery and the seven duplicate ini-based tests.

## Commits

1. **`refactor(config): rename pacquet-npmrc crate to pacquet-config`** — mechanical rename. `crates/npmrc/` → `crates/config/`, `Npmrc` → `Config`, `pacquet-npmrc`/`pacquet_npmrc` references updated workspace-wide. `NpmrcAuth` (the `.npmrc` auth-subset parser) keeps its name.
2. **`refactor(config): drop ini deserializer wiring from Config`** — removes `Deserialize` + `#[serde(rename_all = "kebab-case")]` from `Config`, every per-field `deserialize_with`/`default = "..."` attribute, the helper fns that backed them (`bool_true`, `deserialize_bool`/`u32`/`u64`/`pathbuf`/`store_dir`/`registry`), and the `serde_ini` workspace dep. Drops seven ini-based `lib.rs` tests that already had equivalent yaml/npmrc-auth coverage. Renames `custom_deserializer` → `defaults` (only default factories left), and the `npmrc: &mut Config` local in `apply_to` / `Config::current` becomes `config`.

After the cleanup `Config` is purely a merged runtime struct: yaml deserializes into `WorkspaceSettings` and applies onto a `Config`, `.npmrc` is hand-parsed by `NpmrcAuth::from_ini`, and `Config` itself is no longer deserialized from any file.
2026-05-12 13:45:00 +02:00
Zoltan Kochan
2f64c727ea feat(executor): swallow optional-dep build failures and report via pnpm:skipped-optional-dependency (#397) (#419)
## Summary

Closes item #6 of #397. Optional dependencies whose `postinstall`
hooks fail no longer abort the install — pacquet now reports the
failure through the `pnpm:skipped-optional-dependency` channel
(reason `build_failure`) and continues, matching
[pnpm v11's behavior at `building/during-install/src/index.ts:218-240`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/during-install/src/index.ts#L218-L240).

Three commits:

- **`feat(lockfile): surface snapshot-level optional flag`** —
  pacquet's lockfile reader previously dropped the
  `snapshots[<key>].optional` field. Adding it as `pub optional: bool`
  on `SnapshotEntry` with `#[serde(default, skip_serializing_if = "is_false")]`
  so absent ↔ false and `false` never serializes. The flag is
  pre-computed by pnpm's resolver at install time (ALL-paths-
  optional fold), so pacquet trusts the precomputed value rather
  than re-deriving it — matching [`lockfileToDepGraph` at deps/graph-builder/src/lockfileToDepGraph.ts:315](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/deps/graph-builder/src/lockfileToDepGraph.ts#L315).
- **`feat(reporter): add pnpm:skipped-optional-dependency event`** —
  new `LogEvent::SkippedOptionalDependency(SkippedOptionalDependencyLog)`
  variant + `SkippedOptionalPackage` + `SkippedOptionalReason` enum.
  Wire shape mirrors [pnpm's `SkippedOptionalDependencyMessage`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/core/core-loggers/src/skippedOptionalDependencyLogger.ts).
  All four upstream reasons (`BuildFailure`, `UnsupportedEngine`,
  `UnsupportedPlatform`, `ResolutionFailure`) are declared even
  though only `BuildFailure` is emitted today — keeps the enum
  closed so the other emit sites can land without widening it.
- **`feat(package-manager): swallow optional-dep build failures`** —
  `BuildModules.run` reads `snapshot.optional`. When
  `run_postinstall_hooks` returns an `Err`, the dep's optional
  flag decides: optional ⇒ emit a `pnpm:skipped-optional-dependency`
  event (`reason: build_failure`, `package.id` = the dep's pkg dir
  to match upstream's `depNode.dir`) and continue; non-optional ⇒
  propagate as `BuildModulesError::LifecycleScript` so the install
  aborts. Two `cfg(unix)`-gated tests cover both branches.
2026-05-12 12:59:04 +02:00
Zoltan Kochan
1452682669 feat(executor): port makeEnv, extendPath, and shell selection for lifecycle scripts (#397) (#418)
## Summary

Closes three of the critical items in #397 by porting the corresponding
behaviors from `@pnpm/npm-lifecycle@d2d8e790` and `pnpm/pnpm@b4f8f47ac2`:

- **#1 — Lifecycle env vars.** New `make_env` module ports `makeEnv` and
  the surrounding env block in `lifecycle()` ([npm-lifecycle/index.js:73-104](https://github.com/pnpm/npm-lifecycle/blob/d2d8e790/index.js#L73-L104), [:354-414](https://github.com/pnpm/npm-lifecycle/blob/d2d8e790/index.js#L354-L414)) plus the pnpm wrapper's `extraEnv` additions ([runLifecycleHook.ts:119-124](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/exec/lifecycle/src/runLifecycleHook.ts#L119-L124)). Lifecycle scripts now see `npm_lifecycle_event`, `npm_lifecycle_script`, `npm_node_execpath`/`NODE`, `npm_package_json`, `npm_execpath`, `npm_package_*` (`name`, `version`, and recursive `config`/`engines`/`bin`), `npm_config_node_gyp`, `npm_config_user_agent`, `INIT_CWD`, `PNPM_SCRIPT_SRC_DIR`, and `TMPDIR` (when `unsafe_perm` is false). The spawn now strips inherited env (`env_clear()`) so leftover `npm_*` keys from a wrapping invocation cannot leak through.
- **#2 — PATH ancestor walk.** New `extend_path` module ports `extendPath` ([npm-lifecycle/lib/extendPath.js:5-27](https://github.com/pnpm/npm-lifecycle/blob/d2d8e790/lib/extendPath.js#L5-L27)) plus the tri-state `scriptsPrependNodePath` gating ([:29-61](https://github.com/pnpm/npm-lifecycle/blob/d2d8e790/lib/extendPath.js#L29-L61)). For a dep at `<root>/node_modules/.pnpm/<slot>/node_modules/<pkg>`, `PATH` now contains the dep's own `.bin`, the `.pnpm` slot's `.bin`, the project root's `.bin`, the bundled `node-gyp-bin` (when supplied), `extra_bin_paths`, and finally the inherited PATH.
- **#4 — Shell selection.** New `shell` module ports the [shell-selection block](https://github.com/pnpm/npm-lifecycle/blob/d2d8e790/index.js#L241-L252) and the [pnpm-side `.bat`/`.cmd` guard](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/exec/lifecycle/src/runLifecycleHook.ts#L63-L71). `cmd /d /s /c` on Windows, custom `scriptShell` on either platform, otherwise `sh -c`. The Windows-batch-file `scriptShell` case surfaces as `ERR_PNPM_INVALID_SCRIPT_SHELL_WINDOWS` (matching upstream's error code).

`RunPostinstallHooks` grows seven new fields to surface these knobs; `BuildModules` passes safe defaults (`None` / `true` / `Never`) for all of them — full config plumbing for `user-agent`, `unsafe-perm`, `scripts-prepend-node-path`, `node-gyp` bundling, and `script-shell` are tracked as separate items in #397 (#14, #15) or follow-ups.

### Explicit non-goals in this PR

Three caveats called out in #397 that are deliberately deferred:

- `windowsVerbatimArguments` (Rust equivalent: `CommandExt::raw_arg`) is signalled by `SelectedShell.windows_verbatim_args` but not yet applied to the spawned `Command`.
- `@yarnpkg/shell` / `shellEmulator: true` has no clean Rust port; pacquet ignores the flag for now.
- `unsafe_perm` uid/gid drop (#14) — `BuildModules` passes `true`, which keeps current behavior (no TMPDIR creation, no privilege drop).

### Test parity

Per the project guide ("port the relevant pnpm tests too whenever they translate"), this branch ports:

- `test('makeEnv')` from [npm-lifecycle/test/index.js:97-124](https://github.com/pnpm/npm-lifecycle/blob/d2d8e790/test/index.js#L97-L124).
- The `extendPath` ordering test from [npm-lifecycle/test/extendPath.test.js:5-8](https://github.com/pnpm/npm-lifecycle/blob/d2d8e790/test/extendPath.test.js#L5-L8).
- `runLifecycleHook() does not set npm_config env vars` from [pnpm/exec/lifecycle/test/index.ts:65-77](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/exec/lifecycle/test/index.ts#L65-L77), adapted to a file-dump model so we don't need the IPC fixture.
- `onlyOnWindows('pnpm shows error if script-shell is .cmd')` from [pnpm/exec/commands/test/index.ts:509-542](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/exec/commands/test/index.ts#L509-L542) and the custom-shell case from [:478-508](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/exec/commands/test/index.ts#L478-L508).

Plus fills the gaps where upstream coverage is thin: tri-state `scriptsPrependNodePath`, two-level pnpm virtual-store PATH walks, `extra_bin_paths` ordering, TMPDIR gating on `unsafe_perm`, and `extra_env` precedence vs `npm_lifecycle_script`.
2026-05-12 11:42:51 +02:00
Zoltan Kochan
b8223ec6fc fix(ci): pass tag_name to softprops/action-gh-release
v3 of the action requires an explicit tag_name when GITHUB_REF is not
a tag ref. The release workflow runs on pushes to main, so the API
rejected the create-release call with "Missing tag_name parameter"
once dependabot bumped the action from v1 to v3 in 70eb8538.
2026-05-12 02:29:50 +02:00
Zoltan Kochan
fdbdc09121 chore(release): 0.2.2-6 2026-05-12 02:16:36 +02:00
Khải
bbfa18f12b feat: bin (#333)
Resolves #330.

Mirrors pnpm v11's `@pnpm/bins.resolver`, `@pnpm/bins.linker`, and `@zkochan/cmd-shim` (resolved at pnpm `4750fd370c` and cmd-shim `0d79ca9534`):

- New `pacquet-cmd-shim` crate parses the `bin` field of a package manifest into commands. Both `bin: string | object` and the `directories.bin` glob fallback are supported, with the same path-traversal and URL-safe-name guards as upstream. Conflict resolution follows `pkgOwnsBin` + lexical compare.
- Shim generator writes all three flavors per bin: Unix `/bin/sh` (byte-compatible with pnpm's `generateShShim`), Windows `.cmd` (CRLF, `%~dp0` paths), and PowerShell `.ps1` (`$basedir`/`$exe` header with Windows/POSIX-pwsh detection). Mirrors `generateCmdShim` and `generatePwshShim` minus the unused `nodePath`/`prependToPath`/`progArgs` branches.
- All filesystem IO behind per-capability DI traits — `FsReadHead`, `FsReadFile`, `FsReadString`, `FsReadDir`, `FsWalkFiles`, `FsCreateDirAll`, `FsWrite`, `FsSetExecutable`, `FsEnsureExecutableBits` — following the pattern documented in [#332](https://github.com/pnpm/pacquet/pull/332#issuecomment-4345054524). `FsWrite` is a thin shim over `std::fs::write` and is **not atomic**; a future atomic-write capability can build on it without renaming. Production callers turbofish the unit-struct provider `::<RealApi>`; tests inject unit-struct fakes to cover IO error paths real fs can't trigger portably. Generic param is named `Api` (not `Fs`) so the same provider can grow non-fs capabilities without a rename.
- New `LinkVirtualStoreBins` step walks each virtual-store slot and links its child packages' bins into the slot's own `node_modules/.bin`, matching `linkBinsOfDependencies` in pnpm's `building/during-install`. Slot iteration, per-slot child reads, and shim writes parallelised on rayon.
- `SymlinkDirectDependencies` and `InstallWithoutLockfile` call the bin linker after the symlink layout is in place. Direct-dep linking and the symlink loop are exposed as free `link_direct_dep_bins` / `symlink_direct_deps_into_node_modules` functions so they're unit-testable without the mock-registry scaffold.

Tests port the relevant `bins/resolver` and `bins/linker` cases from pnpm (`directories.bin` discovery, scoped bin names, dangerous locations, idempotent shim skip, conflict resolution) and add fakes-injected coverage for the IO error variants.

**Not in this PR**: hoisted-bin precedence and lifecycle-script-created bins. Both depend on subsystems pacquet hasn't built yet — hoisting (`hoistPattern` / `publicHoistPattern` resolution + the lift step) and lifecycle scripts (`exec/lifecycle/` runner + `allowBuilds` plumbing). The bin-linking side already does the right thing for both (shims are paths, so they pick up files generated post-extract; the precedence rule is a small addition once hoisting tags candidates as hoisted). Tracked separately in  with full porting coordinates.



## Performance

When the bin-linking step landed it added ~7% to the warm-cache Frozen Lockfile install ([benchmark](https://github.com/pnpm/pacquet/pull/333#issuecomment-4344568317)). Closing that gap took a sequence of changes that mirror how pnpm v11 avoids the same costs in its own `linkBinsOfDependencies` ([4750fd370c](https://github.com/pnpm/pnpm/blob/4750fd370c/building/during-install/src/index.ts#L258-L309)):

- **Bundled manifests in `index.db`.** `extract_tarball_entries` captures the parsed `package.json`, picks the subset pnpm caches via a port of `normalizeBundledManifest` (`name`, `version`, `bin`, `directories`, `dependencies`, lifecycle `scripts`, …), and stuffs it into `PackageFilesIndex.manifest`. `prefetch_cas_paths` surfaces it back as a `HashMap<cache_key, Arc<Value>>` so the bin linker doesn't have to re-read `package.json` per child off disk.
- **msgpackr-records encoder learns `serde_json::Value`.** The encoder previously errored with `ManifestNotSupported` if anything ever set `manifest`; now it records-encodes nested JSON objects (slot-allocated per distinct key set) so `useRecords: true` readers see them as JS `Object`s rather than `Map`s — necessary for pnpm's `manifest.bin` / `manifest.directories?.bin` property accesses to resolve.
- **`Arc<Value>` for `PackageBinSource.manifest`.** The lockfile-driven bin-link path produces `slots × children` clones of the parsed manifest (~6k on the integrated-benchmark fixture). Sharing via `Arc` turns each push into a refcount bump.
- **`hasBin` filter via lockfile.** `LinkVirtualStoreBins` now takes the lockfile `packages:` section, pre-computes an `Option<HashSet<PackageKey>>` of `hasBin: true` keys at install start, and skips snapshot children that aren't in the set *before* any path-building or manifest lookup. ~95% of a real lockfile's packages don't declare a bin, so this short-circuits the bulk of the per-slot work. `None` is reserved for the pathological case where the lockfile lacks a `packages:` section (fall back to processing every child); `Some(empty)` is authoritative — lockfile says no package has a bin, every slot short-circuits. Mirrors pnpm's filter at `building/during-install/src/index.ts:283`.
- **Lockfile-driven slot iteration.** `run_lockfile_driven` walks `snapshots` directly and builds slot paths lexically. The previous `run_with_readdir` path enumerated every slot via `Api::read_dir(virtual_store_dir)` and probed each slot's own package directory with a wasted `read_dir` (~1267 wasted `open(O_DIRECTORY) + close` per warm install). The readdir-driven path survives as a fallback for `install_without_lockfile` and retains the existence probe there (small N, no virtual-store invariant from `create_virtual_dir_by_snapshot` to lean on).
- **Parallel prefetch decode.** `StoreIndex::get_many_raw` returns undecoded row bytes; the SQLite mutex releases before `prefetch_cas_paths` fans the msgpackr decode + integrity check across rayon.

Latest CI benchmark on this branch ([job 75462998251](https://github.com/pnpm/pacquet/actions/runs/25701679172/job/75462998251)):

| Scenario | pacquet@HEAD | pacquet@main | Relative |
|---|---|---|---|
| Frozen Lockfile | 2.074s ± 102ms | 2.026s ± 70ms | +2.4% (within noise) |
| Frozen Lockfile (Hot Cache) | **479.6ms ± 38ms** | **535.8ms ± 37ms** | **~10% faster than main** |

The follow-up #417 splits manifests into a dedicated `package_manifests` SQLite table so the unfiltered `package_index` prefetch payload stays at its original size and the manifest fetch becomes a targeted SELECT against just the `hasBin: true` subset.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-12 02:07:14 +02:00
Zoltan Kochan
eb2ae4d32b docs(LICENSE): sync with pnpm/pnpm repo 2026-05-12 01:54:09 +02:00
Khải
a8e8dd3091 ci: setup dylint with perfectionist (#416)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-12 02:11:19 +07:00
dependabot[bot]
2f95d840c9 chore(cargo): bump tokio from 1.52.1 to 1.52.3 (#413)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.1 to 1.52.3.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 12:43:33 +02:00
dependabot[bot]
55b18a488c chore(cargo): bump serde-saphyr from 0.0.25 to 0.0.26 (#415)
Bumps [serde-saphyr](https://github.com/bourumir-wyngs/serde-saphyr) from 0.0.25 to 0.0.26.
- [Release notes](https://github.com/bourumir-wyngs/serde-saphyr/releases)
- [Commits](https://github.com/bourumir-wyngs/serde-saphyr/compare/0.0.25...0.0.26)

---
updated-dependencies:
- dependency-name: serde-saphyr
  dependency-version: 0.0.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 12:42:53 +02:00
dependabot[bot]
5c5b333e17 chore(github-actions): bump codecov/codecov-action from 3 to 6 (#410)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v3...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 12:24:08 +02:00
dependabot[bot]
1d67d0ec67 chore(cargo): bump sysinfo from 0.38.4 to 0.39.1 (#414)
Bumps [sysinfo](https://github.com/GuillaumeGomez/sysinfo) from 0.38.4 to 0.39.1.
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.38.4...v0.39.1)

---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 12:23:39 +02:00
dependabot[bot]
246485c3d5 chore(github-actions): bump EndBug/version-check from 2 to 3 (#412)
Bumps [EndBug/version-check](https://github.com/endbug/version-check) from 2 to 3.
- [Release notes](https://github.com/endbug/version-check/releases)
- [Commits](https://github.com/endbug/version-check/compare/v2...v3)

---
updated-dependencies:
- dependency-name: EndBug/version-check
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 12:22:06 +02:00
dependabot[bot]
89d53ba6b3 chore(github-actions): bump crate-ci/typos from 1.16.22 to 1.46.1 (#411)
Bumps [crate-ci/typos](https://github.com/crate-ci/typos) from 1.16.22 to 1.46.1.
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/v1.16.22...v1.46.1)

---
updated-dependencies:
- dependency-name: crate-ci/typos
  dependency-version: 1.46.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 12:21:42 +02:00
Allan Kimmer Jensen
d685005de9 feat: lifecycle script execution and allowBuilds policy (#391)
## Summary

- Add lifecycle script runner to `pacquet-executor`: preinstall, install, postinstall in correct order, `binding.gyp` auto-detection, `INIT_CWD`/`PNPM_SCRIPT_SRC_DIR`/PATH environment setup
- Add `allowBuilds` build policy matching pnpm 11's default-deny behavior, with `dangerouslyAllowAllBuilds` escape hatch
- Wire build step into the frozen-lockfile install path
- Port `buildSequence` + `graphSequencer` so packages with `requires_build` run children-first
- Add `pnpm:lifecycle` and `pnpm:ignored-scripts` reporter channels — script start/stdio/exit events flow through the same NDJSON pipeline `@pnpm/cli.default-reporter` consumes from upstream pnpm
- Port 11 upstream lifecycle script tests as `known_failures`

Upstream refs:
- [`runPostinstallHooks`](https://github.com/pnpm/pnpm/blob/7e91e4b35f/exec/lifecycle/src/index.ts)
- [`runLifecycleHook`](https://github.com/pnpm/pnpm/blob/80037699fb/exec/lifecycle/src/runLifecycleHook.ts)
- [`createAllowBuildFunction`](https://github.com/pnpm/pnpm/blob/7e91e4b35f/building/policy/src/index.ts)
- [`buildSequence`](https://github.com/pnpm/pnpm/blob/80037699fb/building/during-install/src/buildSequence.ts)
- [`graphSequencer`](https://github.com/pnpm/pnpm/blob/80037699fb/deps/graph-sequencer/src/index.ts)
- [`lifecycleLogger`](https://github.com/pnpm/pnpm/blob/80037699fb/core/core-loggers/src/lifecycleLogger.ts)
- [`ignoredScriptsLogger`](https://github.com/pnpm/pnpm/blob/80037699fb/core/core-loggers/src/ignoredScriptsLogger.ts)

## What's implemented

- `run_postinstall_hooks` in `pacquet-executor` faithfully ports pnpm's hook runner. The hook is now generic over `R: Reporter` and emits `pnpm:lifecycle` events through the standard pacquet reporter pipeline: `Script` before each spawn, one `Stdio` event per stdout/stderr line read through `Stdio::piped()` byline buffering, and `Exit` after the script returns
- `AllowBuildPolicy` reads `pnpm.allowBuilds` and `pnpm.dangerouslyAllowAllBuilds` from `package.json` with correct tri-state semantics (allow/deny/ignored)
- Default-deny matches pnpm 11: packages must be explicitly listed in `allowBuilds` to run their scripts
- `BuildModules::run::<R>` runs lifecycle scripts for `requires_build: true` packages in the frozen-lockfile path and returns the sorted (peer-stripped) list of packages whose scripts were skipped because they weren't in `allowBuilds`. `InstallFrozenLockfile::run` emits one `pnpm:ignored-scripts` event with that list (always, even when empty), mirroring upstream
- `build_sequence` walks the dep graph from each importer, keeps only nodes whose subtree contains a build node, and feeds that subgraph into `graph_sequencer` to produce topologically ordered chunks. `BuildModules` iterates the chunks in order so children build before parents
- `AllowBuildPolicy` and `BuildModules.lockfile_dir` use the existing `requester` field (derived from `manifest.path().parent()`) instead of `std::env::current_dir()`
- New `LogEvent::Lifecycle` and `LogEvent::IgnoredScripts` variants with wire-shape tests
- Recording-fake reporter tests for `run_postinstall_hooks` (Script→Stdio→Exit ordering, non-zero exit propagation) and for `BuildModules::run` (sorted ignored-builds, explicit-deny exclusion). Existing `install_emits_pnpm_event_sequence` updated to expect the new `IgnoredScripts` event between `Stats` and `ImportingDone`
- Unit tests for the build policy, key parsing, `graph_sequencer`, and `build_sequence`

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-11 12:20:33 +02:00
Zoltan Kochan
27499b90eb chore: added codeowner 2026-05-11 02:33:19 +02:00
Zoltan Kochan
c64fbe4d1c docs: gate PR scope to stage 1 of the roadmap (#409)
* docs: gate PR scope to stage 1 of the roadmap

Add a "Scope and Roadmap" section to `CONTRIBUTING.md` and a
`.github/pull_request_template.md` so contributors learn the scope rule
before they write code:

- pacquet only takes work that is listed under stage 1 of the roadmap
  (#299). Stage 2+ items, and any new top-level commands, are out of
  scope and will be closed.
- Stage 1 covers a single command, `install`. Configuration arrives
  through pnpm settings (`.npmrc`, `pnpm-workspace.yaml`), not new
  command-line flags.
- Opening an issue first is optional when the change is in stage 1 and
  exactly mirrors the pnpm CLI; otherwise required.
- Deviating from pnpm's behavior is not an option. Behavior changes
  must land in `pnpm/pnpm` first and be ported afterwards.

* docs: address Copilot review feedback on #409

- Reword the "single command" line: pacquet's CLI today exposes other
  top-level commands. Stage 1 is the focus, not the only thing that
  exists; new top-level commands remain out of scope.
- Drop the mid-sentence em dash from the parity-rule sentence so the
  new text obeys the Writing Style guidance present in the same file.
2026-05-11 01:49:29 +02:00
Khải
e50f912f6e feat(package-manager): write .modules.yaml after install (#407)
* feat(package-manager): write `.modules.yaml` after install

Mirror upstream pnpm's `writeModulesManifest` call at the end of a
successful install (deps-installer/src/install/index.ts L1608-L1630
on commit 086c5e91e8) so `node_modules/.modules.yaml` is persisted
with the resolved layout: `layoutVersion`, `nodeLinker`, the
`included` set derived from the dispatched dependency groups, the
store and virtual-store directories, the `default` registry, and
the `pacquet@<version>` package-manager identifier. This is the
contract a follow-up install (or another tool) keys off to detect
a layout change and prune accordingly.

Adds a focused test that runs an empty-snapshot frozen install and
asserts the on-disk manifest fields, mirroring upstream's
`installing/modules-yaml/test/index.ts` style.

* test(package-manager): use `pipe_as_ref` for modules-yaml read

Address KSXGitHub's review on #407: thread the `modules_dir` path
through `pipe_as_ref` instead of the bare function call, matching
the pipe-trait style used in `crates/modules-yaml/tests/index.rs`.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 23:32:55 +02:00
Khải
ef0fe2c01b docs(cli): fix long and wrong help messages (#405)
* docs(cli): trim verbose --reporter doc comments

The doc comments on `CliArgs.reporter` and `ReporterType` had grown into
multi-paragraph explanations with markdown backticks, internal-attribute
notes (`global = true`), and GitHub issue references that all leaked
straight into `pacquet --help` output. Match pnpm's terse style: a single
short line on the field, no enum-level prose. Per-variant docs already
describe what `ndjson` and `silent` do.

* docs: re-add docs with improvement

* docs: more specific message

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-09 22:16:42 +02:00
Khải
0df762b02c feat: propagate pnpm-workspace.yaml load errors (#404)
Resolves <https://github.com/pnpm/pacquet/issues/402>.

## What this PR does

Pacquet already reads `pnpm-workspace.yaml` (`crates/npmrc/src/workspace_yaml.rs` defines `WorkspaceSettings` and `Npmrc::current` walks up from cwd to layer it on top of `.npmrc`). The remaining divergence from pnpm 11 was on the error path. Upstream's [`workspace-manifest-reader`](https://github.com/pnpm/pnpm/blob/8eb1be4988/workspace/workspace-manifest-reader/src/index.ts) silently treats `ENOENT` as "no manifest" and propagates every other failure (read errors, parse errors, `EISDIR`, permission denied, …). Pacquet swallowed all errors via `if let Ok(Some(...))`, and its `find_workspace_manifest` helper used an `is_file()` filter that also masked non-regular entries (e.g. a directory named `pnpm-workspace.yaml`).

This PR closes both gaps.

## Changes

- `crates/npmrc/src/workspace_yaml.rs`
  - Replace the bare `Debug`-only `LoadWorkspaceYamlError` with a `Display + Error + Diagnostic` enum that stores `path` + `source` directly on each struct variant, mirroring `pacquet_modules_yaml::ReadModulesError`. Only `serde_saphyr::Error` is boxed (`io::Error` fits the variant inline). The single-place `path` formatting keeps the miette `Caused by:` chain free of duplication.
  - Rewrite `find_and_load` as a single read-and-catch loop over `start_dir.ancestors()`: at each level read `pnpm-workspace.yaml`, treat only `ErrorKind::NotFound` as "no manifest here, walk up", and propagate every other error through `LoadWorkspaceYamlError`. This matches pnpm's `readManifestRaw` semantics, closes the previous TOCTOU window between the old `is_file()` check and the read, and surfaces non-regular entries (directory, EISDIR, permission denied) as hard errors instead of silently walking up.
- `crates/npmrc/src/lib.rs`
  - `Npmrc::current` is now `Result<Self, LoadWorkspaceYamlError>`. The `.npmrc` parsing stays best-effort; the yaml parsing fails loud.
  - Two regression tests added in this round: `invalid_workspace_yaml_propagates_error` (parse error) and `find_propagates_when_manifest_path_is_a_directory` (`EISDIR`-class error). Existing tests adopt the fallible signature with `.expect(...)`.
- `crates/cli/src/cli_args.rs`
  - The `npmrc` closure now returns `miette::Result<&'static mut Npmrc>` and is wrapped with `wrap_err("load configuration")`. The `state` closure threads `?`.
- `crates/cli/src/cli_args/store.rs`
  - `StoreCommand::run` accepts `impl FnOnce() -> miette::Result<&Npmrc>` so `Prune` and `Path` get the new error path; `Store` and `Add` still panic before loading config.
- `crates/npmrc/Cargo.toml`
  - Add `derive_more` and `miette` deps for the new error type.
2026-05-09 22:11:43 +02:00
Khải
837a920022 refactor(npmrc): collapse WorkspaceSettings::apply_to with macro (#406)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-08 13:16:46 +02:00