Skip to content

feat(pacquet): configurational dependencies support#12285

Merged
zkochan merged 17 commits into
mainfrom
config-deps-pq
Jun 9, 2026
Merged

feat(pacquet): configurational dependencies support#12285
zkochan merged 17 commits into
mainfrom
config-deps-pq

Conversation

@zkochan

@zkochan zkochan commented Jun 9, 2026

Copy link
Copy Markdown
Member

Ports pnpm's configDependencies feature to pacquet end-to-end: config dependencies are resolved and installed ahead of regular deps into node_modules/.pnpm-config/<name> via the global virtual store, recorded in the env lockfile (the first YAML document of pnpm-lock.yaml), their updateConfig plugin hooks run before the main install (including patchedDependencies/catalogs injection), and pnpm add --config manages them.

The whole path runs via pacquet install / pacquet add --config and is covered by tests against the mocked registry.

What's implemented

Resolve + install (pacquet-env-installer) — clean-specifier resolution + migration of the old inline (version+integrity) / object ({ tarball?, integrity }) formats; one level of optional subdeps with os/cpu/libc platform fields + host filtering (exact-version-only); env-lockfile pruning of stale entries; pnpm error codes (ERR_PNPM_BAD_CONFIG_DEP, CONFIG_DEP_OPTIONAL_NOT_EXACT, ENV_LOCKFILE_CORRUPTED, FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE, …).

updateConfig pnpmfile hook (pacquet-hooks + pacquet-config) — new updateConfig trait method + Node-worker dispatch; is_plugin_name + plugin-pnpmfile resolution (pnpm-plugin-* / @pnpm/plugin-* / @scope/pnpm-plugin-*) from .pnpm-config; full Config round-trip via WorkspaceSettings (added Serialize), applying only hook-changed keys so .npmrc/CLI values aren't clobbered; catalog/catalogs are seeded into the hook input and captured back into Config::catalogs (the install prefers it over re-reading the manifest), so a hook can inject catalogs a catalog: dependency then resolves against.

pnpm add --config (pacquet-cli + pacquet-workspace-manifest-writer) — resolves + installs the dep and writes the clean specifier into pnpm-workspace.yaml's configDependencies block with a format-preserving edit (extends the previously catalog-only writer).

Wiring (pacquet-cli) — config-dep install + updateConfig hooks run at config finalization, before the main install.

Supportinggraph-hasher: calc_leaf_global_virtual_store_path + calc_global_virtual_store_path_with_subdeps; lockfile: EnvLockfile multi-document read/write + env-doc preservation in the wanted-lockfile writer; reporter: pnpm:installing-config-deps event.

Tests

env-installer integration (7, incl. old-format migration and frozen-up-to-date), env-lockfile + GVS units, hooks (updateConfig + plugin matchers, 4), manifest-writer config-dep (4), and CLI e2e (5): config dep installs + links + env lockfile; repeat install no-op; updateConfig flips nodeLinker; updateConfig injects a catalog a catalog: dep resolves against; add --config writes the yaml block and installs. Each push passes the pre-push checks (fmt, taplo, cargo doc, dylint Perfectionist).

Notes

  • Further upstream pnpm tests can always be ported, but the core behaviors above are covered.

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

Summary by CodeRabbit

  • New Features

    • Add and manage workspace configDependencies (new --config flag for add); writes pnpm-workspace.yaml.
    • Installs materialize configDependencies under node_modules/.pnpm-config and preserve an env document in pnpm-lock.yaml.
    • Support updateConfig hooks (pnpmfile) and plugin discovery for config-deps.
    • New NDJSON log channel reporting installing-config-deps events.
    • Configs may carry injected catalogs used during resolution.
  • Tests

    • Added extensive unit and end-to-end tests for config deps, env lockfile behavior, hooks, pruning, and workspace edits.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a636755f-d933-4fd8-894d-8c6608c96026

📥 Commits

Reviewing files that changed from the base of the PR and between 3f8994e and 6e284b5.

📒 Files selected for processing (2)
  • pacquet/crates/lockfile/src/env_lockfile.rs
  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
📜 Recent review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: Dylint
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Code Coverage
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Compile & Lint
🧰 Additional context used
📓 Path-based instructions (1)
pacquet/**/*.rs

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/*.rs: Log emissions are part of matching pnpm — when porting a function that fires pnpm:<channel> events through globalLogger, logger.debug(...), or streamParser.write(...), mirror the call site, payload, and ordering so @pnpm/cli.default-reporter parses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing the brand into a plain String or &str in Rust
If upstream TypeScript always validates before construction of a branded string, validate in the Rust wrapper too via TryFrom<String> and/or FromStr and do not provide an infallible public constructor
If upstream TypeScript never validates a branded string, just brand for type-safety in Rust by exposing an infallible From<String> constructor
If upstream TypeScript occasionally constructs a branded string without validation, expose from_str_unchecked in Rust as an escape hatch alongside the validating constructor
Match upstream serde behavior for branded strings crossing JSON, YAML, or INI boundaries by using #[serde(try_from = "String")] for deserialization and #[serde(into = "String")] for serialization
Derive simple conversions for branded strings using #[derive(derive_more::From)] and #[derive(derive_more::Into)] instead of handwriting impl blocks; use manual impl only when conversion needs custom logic
Model TypeScript string literal unions (like 'auto' | 'always' | 'never') as Rust enums instead of newtype wrappers, since the set of valid values is closed
Treat TypeScript string template literal types (like `${string}@${string}`) the same as branded string types in Rust, using a newtype wrapper with validation
Follow the code style guide in CODE_STYLE_GUIDE.md — imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching, pipe-trait, error handling, test layout, and cloning of Arc and Rc
Choose owned vs. borrowed parameters to minimize copies; widen to t...

Files:

  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
🧠 Learnings (5)
📚 Learning: 2026-05-20T19:40:55.051Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11774
File: pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs:0-0
Timestamp: 2026-05-20T19:40:55.051Z
Learning: In the pacquet Rust code, ensure the semver implementation uses the `node-semver` crate (not `nodejs-semver`). `node-semver`’s public API does not include a `satisfies_with_prerelease`-style method; prerelease-tolerant matching should be implemented inline by first calling `Range::satisfies`, and when it rejects a prerelease version, retry matching against a stripped `MAJOR.MINOR.PATCH` base of the prerelease version.

Applied to files:

  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
📚 Learning: 2026-05-22T00:08:44.646Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11837
File: pacquet/crates/resolving-npm-resolver/src/pick_package.rs:33-51
Timestamp: 2026-05-22T00:08:44.646Z
Learning: In the pnpm/pnpm repo’s pacquet Rust crates, do not flag Unicode ellipsis characters (U+2026, `…`) in Rust doc comments (`///` / `/** */`) as a lint violation. The pacquet crate’s `dylint.toml` only enables `perfectionist::derive_ordering`, and the Dylint `unicode-ellipsis` rule is not enabled for this project—so `…` in doc comments is an intentional, repo-consistent style.

Applied to files:

  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
📚 Learning: 2026-05-20T23:07:58.444Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11784
File: pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs:120-133
Timestamp: 2026-05-20T23:07:58.444Z
Learning: When reviewing code in this pacquet Rust port, follow the upstream pnpm compatibility rule: only match pnpm’s behavior exactly. Do not propose review changes that intentionally deviate from pnpm’s documented/observed behavior, even if pnpm appears buggy. If you identify a real bug in pnpm behavior, the review should prioritize fixing it upstream in pnpm first, and avoid implementing a pnpm-behavior workaround here unless the same fix has already landed upstream.

Applied to files:

  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
📚 Learning: 2026-05-20T23:23:40.606Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11788
File: pacquet/crates/lockfile/src/snapshot_dep_ref.rs:52-56
Timestamp: 2026-05-20T23:23:40.606Z
Learning: In the pacquet lockfile crate, the link target is intentionally represented as a raw `Link(String)` (not a `LinkTarget` newtype) for both `SnapshotDepRef::Link` and `ImporterDepVersion::Link`. pnpm upstream preserves `link:` targets verbatim with only a prefix check (e.g., `refToRelative`), so do not suggest introducing a `LinkTarget` newtype in a bug-fix/targeted PR. If type branding is ever desired, it should be done together for both variants in a separate dedicated refactor PR rather than as part of a small fix.

Applied to files:

  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
📚 Learning: 2026-06-06T18:58:37.156Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 12243
File: pacquet/crates/package-manager/src/install_package_by_snapshot.rs:319-322
Timestamp: 2026-06-06T18:58:37.156Z
Learning: When reviewing Rust code, do not assume `matches!(expr, Pattern(_))` will move out of `expr` if `Pattern(_)` contains no by-value bindings. `matches!` desugars to a `match` that auto-borrows the scrutinee for discrimination, so even if `expr` is a non-`Copy` value behind a shared reference (e.g., `&T`), the macro should not move-out of the borrowed data purely due to `matches!`. Treat `matches!(&expr, Pattern(_))` as a readability/clarity improvement, not a correctness requirement. Only flag potential move-out-of-borrow risks when the pattern includes by-value bindings that would require moving the matched value.

Applied to files:

  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
🔇 Additional comments (2)
pacquet/crates/lockfile/src/env_lockfile.rs (1)

21-22: LGTM!

Also applies to: 68-74, 97-100

pacquet/crates/lockfile/src/env_lockfile/tests.rs (1)

58-74: LGTM!


📝 Walkthrough

Walkthrough

Adds end-to-end support for pnpm-style configDependencies: an env lockfile document, resolver + env-installer crate to materialize config deps into node_modules/.pnpm-config, updateConfig hook execution, workspace manifest edits for configDependencies, CLI wiring for install/add, reporter events, hashing helpers, and tests.

Changes

Config Dependencies and UpdateConfig Hooks

Layer / File(s) Summary
Workspace wiring and config contracts
Cargo.toml, pacquet/crates/config/src/*, pacquet/crates/cli/src/lib.rs
Adds workspace crate dependency for env-installer, pacquet-catalogs-types, and serde Serialize derives plus Config.catalogs field and related wiring.
Env lockfile format and YAML persistence
pacquet/crates/lockfile/src/{env_lockfile.rs,yaml_documents.rs,save_lockfile.rs,resolution.rs}
Adds EnvLockfile model as leading YAML document in pnpm-lock.yaml, read/write preserves main document, and exposes YAML helpers; npm_tarball_url made public.
Env-installer crate: core types & options
pacquet/crates/env-installer/{Cargo.toml,src/lib.rs,src/options.rs,src/parse_integrity.rs,src/errors.rs}
New crate exposing ConfigDepsInstallOptions, parse_integrity types, ConfigDepError, and crate wiring used by resolver/install phases.
Config dependency resolution, migration & pruning
pacquet/crates/env-installer/src/{resolve_and_install_config_deps.rs,resolve_optional_subdeps.rs,prune.rs,tests.rs}
Implements resolve_and_install_config_deps with legacy migration, optional-subdep resolution (one level), frozen-lockfile rules, env-lockfile pruning, and extensive tests.
Config dependency materialization & optional-subdep install
pacquet/crates/env-installer/src/install_config_deps.rs, pacquet/crates/env-installer/src/*
Materializes config deps into GVS layout, manages .pnpm-config/<name> links, handles optional-subdep installability/pruning/link checks, and emits installing-config-deps reporter events.
Global virtual store hashing for config deps
pacquet/crates/graph-hasher/src/{global_virtual_store_path.rs,tests.rs,lib.rs}
Adds calc_leaf_global_virtual_store_path and calc_global_virtual_store_path_with_subdeps for GVS paths including optional subdeps; updates exports and tests.
UpdateConfig hook support and plugin discovery
pacquet/crates/hooks/src/{lib.rs,finder.rs,node_runtime.rs}, pacquet/crates/hooks/tests/node_hooks.rs
Adds PnpmfileHooks::update_config and Node runtime execution, pnpmfile plugin discovery helpers, and tests for hook execution and discovery.
Workspace manifest configDependencies support
pacquet/crates/workspace-manifest-writer/src/{lib.rs,edit.rs,model.rs,tests.rs}
Adds set_config_dependency and add_config_dependency editor that upserts configDependencies with format-preserving behavior and tests.
CLI integration: install/add wiring
pacquet/crates/cli/src/{lib.rs,cli_args.rs,cli_args/add.rs,config_deps.rs}, pacquet/crates/cli/Cargo.toml, pacquet/crates/cli/tests/config_dependencies.rs
Wires install_config_deps, add_config_dependency, and run_update_config_hooks into pacquet install and pacquet add --config; install sequence runs config dep resolve/install, updateConfig hooks, freezes mutated config, then initializes State and runs install; adds integration tests.
Config dependency logging
pacquet/crates/reporter/src/lib.rs
Adds pnpm:installing-config-deps NDJSON channel with started/done statuses and installed deps payload.
Lockfile save integration
pacquet/crates/lockfile/src/save_lockfile.rs, pacquet/crates/lockfile/src/yaml_documents.rs
save_value_to_path preserves a leading env YAML document when writing the main lockfile; helpers exposed for extraction.
Integration and unit tests
pacquet/crates/{env-installer,cli,lockfile,workspace-manifest-writer,graph-hasher}/tests*
Extensive tests added covering env lockfile round-trip, resolve/install flows, optional subdeps behavior and validation, frozen-lockfile behavior, manifest edits, hook execution, reporter events, and GVS hashing.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • pnpm/pnpm#11892: Related pruning behavior for env lockfile entries.
  • pnpm/pnpm#11787: Related catalogs/cat-types infrastructure used by this change.
  • pnpm/pnpm#11734: Integration boundary work overlapping CLI/reporting plumbing.

Poem

🐰 I hop through lockfiles, links, and hooks,
Sewing catalogs into hidden books,
Symlinks set, and envs take root,
Tests snug as carrots in a boot,
Hooray — a comfy install nook!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch config-deps-pq

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.08      8.6±0.26ms   507.3 KB/sec    1.00      7.9±0.18ms   545.9 KB/sec

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 10.070 ± 0.370 9.782 11.029 1.88 ± 0.08
pacquet@main 9.841 ± 0.041 9.791 9.935 1.84 ± 0.03
pnpr@HEAD 5.347 ± 0.092 5.266 5.596 1.00
pnpr@main 5.401 ± 0.129 5.305 5.662 1.01 ± 0.03
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 10.0703398826,
      "stddev": 0.3695879452585777,
      "median": 9.9612328988,
      "user": 2.98987604,
      "system": 3.2126489200000004,
      "min": 9.7823590063,
      "max": 11.0286917353,
      "times": [
        11.0286917353,
        10.0631564833,
        10.206252415300002,
        9.833150057300001,
        10.1382513913,
        9.7823590063,
        9.8593093143,
        9.8569628283,
        9.8308299793,
        10.104435615300002
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 9.8407284748,
      "stddev": 0.04094713702659372,
      "median": 9.836526048800001,
      "user": 2.96658284,
      "system": 3.22110982,
      "min": 9.7913178493,
      "max": 9.9349875023,
      "times": [
        9.863381028300001,
        9.856237098300001,
        9.7913178493,
        9.9349875023,
        9.843603509300001,
        9.853953671300001,
        9.829448588300002,
        9.816601789300002,
        9.8022200243,
        9.8155336873
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 5.34748012,
      "stddev": 0.09197809226576023,
      "median": 5.3269097493,
      "user": 2.4538090399999994,
      "system": 2.93300292,
      "min": 5.2656684263,
      "max": 5.5957745593,
      "times": [
        5.3781132563,
        5.3017191413,
        5.3341543403000005,
        5.3237631363,
        5.3308129813,
        5.3179716343,
        5.2656684263,
        5.5957745593,
        5.2967673623,
        5.3300563623
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 5.4008227164,
      "stddev": 0.12903773415225914,
      "median": 5.3261791252999995,
      "user": 2.4007877400000006,
      "system": 2.93641702,
      "min": 5.3049559183,
      "max": 5.6615578453,
      "times": [
        5.3071346393,
        5.3049559183,
        5.3083550093,
        5.4202088113,
        5.3193353873,
        5.6615578453,
        5.3051098033,
        5.3330228633,
        5.4789871473,
        5.5695597393
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 637.7 ± 16.3 620.0 661.9 1.00
pacquet@main 647.1 ± 24.0 622.0 707.6 1.01 ± 0.05
pnpr@HEAD 815.7 ± 94.8 747.7 1014.7 1.28 ± 0.15
pnpr@main 781.7 ± 75.3 714.4 933.1 1.23 ± 0.12
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6376648414600002,
      "stddev": 0.016250354529034897,
      "median": 0.6336530353600001,
      "user": 0.36915541999999996,
      "system": 1.3066455599999998,
      "min": 0.6200019038600001,
      "max": 0.6618892288600001,
      "times": [
        0.6608509148600001,
        0.6374716018600001,
        0.6618892288600001,
        0.6230288748600001,
        0.65432576486,
        0.6255236538600001,
        0.6200019038600001,
        0.6411438988600001,
        0.6225781038600001,
        0.62983446886
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6470982485600001,
      "stddev": 0.02398907430867578,
      "median": 0.6447370388600001,
      "user": 0.36442882,
      "system": 1.32245006,
      "min": 0.6220172378600001,
      "max": 0.7076022058600001,
      "times": [
        0.6478445448600001,
        0.6220172378600001,
        0.7076022058600001,
        0.6314737978600001,
        0.6521056838600001,
        0.6336059378600001,
        0.65740846086,
        0.6426388018600001,
        0.6294505388600001,
        0.6468352758600001
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.81573911686,
      "stddev": 0.09476172319908027,
      "median": 0.7731097758600001,
      "user": 0.38832732,
      "system": 1.3547038599999999,
      "min": 0.7477432648600001,
      "max": 1.01468801586,
      "times": [
        0.9704157098600001,
        0.7477432648600001,
        0.7938157888600001,
        0.7626292198600001,
        0.79188836786,
        0.7692619168600001,
        1.01468801586,
        0.7769576348600001,
        0.7634113578600001,
        0.7665798918600001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.7816775815600002,
      "stddev": 0.0753041469068164,
      "median": 0.7572575558600001,
      "user": 0.38313762,
      "system": 1.31286346,
      "min": 0.71439316086,
      "max": 0.9330668608600001,
      "times": [
        0.7609093788600001,
        0.74275477586,
        0.9330668608600001,
        0.7623581638600001,
        0.7291183118600001,
        0.7621902278600001,
        0.9092706048600001,
        0.7536057328600001,
        0.7491085978600001,
        0.71439316086
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 9.184 ± 0.030 9.143 9.228 1.78 ± 0.04
pacquet@main 9.193 ± 0.041 9.129 9.252 1.78 ± 0.04
pnpr@HEAD 5.152 ± 0.125 5.061 5.416 1.00
pnpr@main 5.176 ± 0.160 5.015 5.467 1.00 ± 0.04
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 9.18372792668,
      "stddev": 0.030325245383382844,
      "median": 9.17665877688,
      "user": 3.4763640800000006,
      "system": 3.2513979999999996,
      "min": 9.142910991379999,
      "max": 9.228005377379999,
      "times": [
        9.19217019438,
        9.18219419938,
        9.14634440238,
        9.16669302138,
        9.21100277938,
        9.17053942438,
        9.228005377379999,
        9.142910991379999,
        9.171123354379999,
        9.22629552238
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 9.19254601208,
      "stddev": 0.041014226837257584,
      "median": 9.19165280288,
      "user": 3.49179308,
      "system": 3.2322397999999994,
      "min": 9.12915414438,
      "max": 9.25190539838,
      "times": [
        9.23934662538,
        9.15139198538,
        9.12915414438,
        9.18557664838,
        9.197728957379999,
        9.25190539838,
        9.22333033338,
        9.17795934338,
        9.21880576738,
        9.150260917379999
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 5.151704176680001,
      "stddev": 0.12496576171536285,
      "median": 5.096387287380001,
      "user": 2.29415588,
      "system": 2.8213420999999994,
      "min": 5.06056963638,
      "max": 5.415765660380001,
      "times": [
        5.33498004638,
        5.10470428238,
        5.08807029238,
        5.067939869380001,
        5.06056963638,
        5.066545777380001,
        5.415765660380001,
        5.1122572143800005,
        5.1885541363800005,
        5.07765485138
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 5.1759807884799995,
      "stddev": 0.1598132596643247,
      "median": 5.109305944880001,
      "user": 2.24577958,
      "system": 2.8028398,
      "min": 5.01501094738,
      "max": 5.467345814380001,
      "times": [
        5.12878238638,
        5.44623044438,
        5.06179022438,
        5.089829503380001,
        5.01501094738,
        5.467345814380001,
        5.07071246238,
        5.23635332038,
        5.084777170380001,
        5.158975611380001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.368 ± 0.012 1.349 1.391 2.03 ± 0.10
pacquet@main 1.379 ± 0.025 1.353 1.446 2.05 ± 0.10
pnpr@HEAD 0.674 ± 0.032 0.648 0.747 1.00
pnpr@main 0.675 ± 0.054 0.647 0.826 1.00 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.3676391154,
      "stddev": 0.012370554676851364,
      "median": 1.3686475786,
      "user": 1.5073400399999997,
      "system": 1.76222866,
      "min": 1.3488408901000002,
      "max": 1.3914848871,
      "times": [
        1.3695124211,
        1.3677827361000001,
        1.3776871871,
        1.3659673321,
        1.3914848871,
        1.3518691431,
        1.3488408901000002,
        1.3596149371000001,
        1.3719253051000002,
        1.3717063151000002
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.3790689703,
      "stddev": 0.02489673445760907,
      "median": 1.3714326766,
      "user": 1.5032406399999998,
      "system": 1.7775510599999997,
      "min": 1.3533942541000001,
      "max": 1.4462532521000002,
      "times": [
        1.4462532521000002,
        1.3688837551000002,
        1.3690529531,
        1.3708155571,
        1.3533942541000001,
        1.3798362811,
        1.3720497961,
        1.3805047051000001,
        1.3798317831,
        1.3700673661
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.673614452,
      "stddev": 0.03208226599473443,
      "median": 0.6608489416,
      "user": 0.33028574,
      "system": 1.2703459599999998,
      "min": 0.6480463281,
      "max": 0.7471574401,
      "times": [
        0.7471574401,
        0.7150858261,
        0.6725994191,
        0.6480463281,
        0.6525269151,
        0.6614090811,
        0.6602888021,
        0.6532134391,
        0.6685469981,
        0.6572702711
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6745691866,
      "stddev": 0.05443455429539115,
      "median": 0.6543729106,
      "user": 0.32672614,
      "system": 1.2651306599999999,
      "min": 0.6466950461,
      "max": 0.8255512431,
      "times": [
        0.6906937651,
        0.6527167501,
        0.6512316831,
        0.6466950461,
        0.6604459001,
        0.8255512431,
        0.6531815651,
        0.6574611571,
        0.6555642561,
        0.6521505001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.971 ± 0.034 4.927 5.017 7.56 ± 0.27
pacquet@main 4.956 ± 0.019 4.922 4.986 7.53 ± 0.27
pnpr@HEAD 0.690 ± 0.054 0.649 0.835 1.05 ± 0.09
pnpr@main 0.658 ± 0.023 0.637 0.719 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.970603273139999,
      "stddev": 0.033540979187953526,
      "median": 4.97800098634,
      "user": 1.6709779999999999,
      "system": 1.86960394,
      "min": 4.92679980434,
      "max": 5.01748349034,
      "times": [
        4.9958842383399995,
        4.94484278534,
        4.9385026733399995,
        5.004340398339999,
        5.01748349034,
        4.98803872234,
        4.99306590634,
        4.9679632503399995,
        4.92911146234,
        4.92679980434
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.95601024484,
      "stddev": 0.019013810957542274,
      "median": 4.96054214634,
      "user": 1.6821370999999998,
      "system": 1.8909357399999998,
      "min": 4.92186073934,
      "max": 4.9864182993399995,
      "times": [
        4.931596616339999,
        4.9559568583399995,
        4.96618139034,
        4.94321749634,
        4.96396418534,
        4.95902062234,
        4.92186073934,
        4.96982257034,
        4.96206367034,
        4.9864182993399995
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.69024275944,
      "stddev": 0.05415720988906426,
      "median": 0.66738631534,
      "user": 0.32244429999999996,
      "system": 1.30604564,
      "min": 0.64852599634,
      "max": 0.83520502234,
      "times": [
        0.71231490334,
        0.83520502234,
        0.66804870634,
        0.66519598434,
        0.66460769834,
        0.66267762934,
        0.66672392434,
        0.64852599634,
        0.68388329134,
        0.69524443834
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.65779551844,
      "stddev": 0.023071785532128155,
      "median": 0.65096137734,
      "user": 0.3182223999999999,
      "system": 1.26003974,
      "min": 0.63747758834,
      "max": 0.71907115634,
      "times": [
        0.71907115634,
        0.64667868734,
        0.66350442734,
        0.65562614134,
        0.65089713134,
        0.64241276334,
        0.64779050034,
        0.63747758834,
        0.6634711653400001,
        0.65102562334
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12285
Testbedpacquet

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-storeLatency
seconds (s)
📈 plot
🚷 threshold
🚨 alert (🔔)
9.18 s
(+37.34%)Baseline: 6.69 s
8.02 s
(114.45%)

Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
🚨 view alert (🔔)
9,183.73 ms
(+37.34%)Baseline: 6,686.85 ms
8,024.22 ms
(114.45%)

isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
4,970.60 ms
(-0.87%)Baseline: 5,014.36 ms
6,017.23 ms
(82.61%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,367.64 ms
(-2.58%)Baseline: 1,403.86 ms
1,684.63 ms
(81.18%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
10,070.34 ms
(+14.49%)Baseline: 8,795.58 ms
10,554.69 ms
(95.41%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
637.66 ms
(-2.11%)Baseline: 651.39 ms
781.67 ms
(81.58%)
🐰 View full continuous benchmarking report in Bencher

zkochan added 10 commits June 9, 2026 16:38
Ports pnpm's `@pnpm/installing.env-installer` to pacquet: resolves and
installs `configDependencies` declared in pnpm-workspace.yaml into
node_modules/.pnpm-config/<name> via the global virtual store, recording
them in the env lockfile (the first YAML document of pnpm-lock.yaml).

New `pacquet-env-installer` crate:
- resolve_and_install_config_deps: clean-specifier resolution plus
  migration of the old inline (`version+integrity`) and object
  (`{ tarball?, integrity }`) formats
- one level of optional subdeps with os/cpu/libc platform fields and
  host filtering; exact-version-only enforcement
- env-lockfile pruning of stale packages/snapshots on re-resolve
- pnpm error codes (ERR_PNPM_BAD_CONFIG_DEP, CONFIG_DEP_OPTIONAL_NOT_EXACT,
  ENV_LOCKFILE_CORRUPTED, FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE, ...)

Supporting changes:
- graph-hasher: calc_leaf_global_virtual_store_path and
  calc_global_virtual_store_path_with_subdeps
- lockfile: EnvLockfile type + multi-document read/write; the wanted
  lockfile writer now preserves the env document
- reporter: pnpm:installing-config-deps event

Verified with 5 integration tests against the mocked registry plus
graph-hasher and env-lockfile unit tests.

Draft / work in progress. Still to land: the updateConfig pnpmfile hook
primitive (plugin hooks), wiring config-dep install into the install
command at config finalization, `add --config`, and the remaining
ported pnpm tests.
@codecov-commenter

codecov-commenter commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.79245% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.53%. Comparing base (d188316) to head (6e284b5).

Files with missing lines Patch % Lines
...et/crates/env-installer/src/install_config_deps.rs 85.89% 56 Missing ⚠️
...v-installer/src/resolve_and_install_config_deps.rs 84.79% 26 Missing ⚠️
...ates/env-installer/src/resolve_optional_subdeps.rs 78.94% 24 Missing ⚠️
pacquet/crates/cli/src/config_deps.rs 91.57% 15 Missing ⚠️
pacquet/crates/config/src/lib.rs 0.00% 12 Missing ⚠️
pacquet/crates/cli/src/cli_args/add.rs 68.42% 6 Missing ⚠️
...acquet/crates/env-installer/src/parse_integrity.rs 76.92% 3 Missing ⚠️
pacquet/crates/lockfile/src/env_lockfile.rs 92.30% 3 Missing ⚠️
pacquet/crates/env-installer/src/prune.rs 92.00% 2 Missing ⚠️
pacquet/crates/hooks/src/lib.rs 0.00% 2 Missing ⚠️
... and 4 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12285      +/-   ##
==========================================
- Coverage   87.55%   87.53%   -0.03%     
==========================================
  Files         280      288       +8     
  Lines       33664    34816    +1152     
==========================================
+ Hits        29476    30475     +999     
- Misses       4188     4341     +153     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@zkochan zkochan marked this pull request as ready for review June 9, 2026 15:07
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (8) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0)

Grey Divider


Action required

1. Config dep path traversal 🐞 Bug ⛨ Security
Description
Config-dependency names are used directly in Path::join(...) for creating/pruning .pnpm-config
entries and locating plugin pnpmfiles, without validating they are safe npm package names. A crafted
configDependencies key (e.g. containing extra /, .., or an absolute path) can escape
.pnpm-config and lead to unintended directory deletion/symlink placement or loading an unintended
pnpmfile.
Code

pacquet/crates/env-installer/src/install_config_deps.rs[R44-55]

+    let config_modules_dir = opts.root_dir.join("node_modules").join(".pnpm-config");
+
+    let existing: Vec<String> = read_dir_names(&config_modules_dir)?;
+
+    let mut started = StartedGate::new();
+
+    // Drop config deps that are no longer declared.
+    for name in &existing {
+        if !normalized.contains_key(name) {
+            started.report::<Reporter>();
+            prune_link(&config_modules_dir.join(name));
+        }
Evidence
install_config_deps joins config-dependency names into filesystem paths for pruning/symlinking,
and hooks::finder joins the same names to locate plugin pnpmfiles; meanwhile pnpm-workspace.yaml
configDependencies keys are parsed as raw strings without validation. The repo already contains a
strict npm package-name validator that would prevent unsafe path components (extra /, leading .,
etc.), but it is not applied to configDependencies.

pacquet/crates/env-installer/src/install_config_deps.rs[36-79]
pacquet/crates/hooks/src/finder.rs[32-76]
pacquet/crates/config/src/workspace_yaml.rs[210-219]
pacquet/crates/resolving-parse-wanted-dependency/src/validate_npm_package_name.rs[31-57]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Config dependency names (from `pnpm-workspace.yaml` / env lockfile) are used as path components via `Path::join(name)` when pruning/creating `.pnpm-config` entries and when discovering plugin pnpmfiles. Because these names are not validated as npm package names, a malicious name can introduce `..`/extra path separators/absolute paths and cause filesystem traversal outside `.pnpm-config`.
## Issue Context
There is already a canonical npm-name validator (`is_valid_old_npm_package_name`) used in dependency parsing, but configDependencies keys are deserialized as `BTreeMap<String, ...>` without validation and then consumed by env-installer + hook finder.
## Fix Focus Areas
- pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[27-100]
- pacquet/crates/env-installer/src/install_config_deps.rs[36-80]
- pacquet/crates/hooks/src/finder.rs[32-78]
- pacquet/crates/config/src/workspace_yaml.rs[210-220]
### Implementation notes
- Reject invalid config-dependency names up-front (before any lockfile mutation or filesystem operations) using `pacquet_resolving_parse_wanted_dependency::is_valid_old_npm_package_name` (or an equivalent shared validator).
- On invalid names, return `ConfigDepError::BadConfigDep { ... }` (ERR_PNPM_BAD_CONFIG_DEP).
- In addition to validation, defensively guard path usage by rejecting names whose `Path::new(name).components()` contains `RootDir`/`Prefix`/`ParentDir` or more than one `/` for scoped packages.
- Apply the same validation filter in `calc_pnpmfile_paths_of_plugin_deps` so plugin discovery never joins unsafe names.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Env lockfileVersion incompatible ✓ Resolved 🐞 Bug ≡ Correctness
Description
EnvLockfile parsing requires a numeric lockfileVersion (ComVer/LockfileVersion<9>), so it will fail
to read an env document whose lockfileVersion is a non-numeric string like "env-1.0". This can
hard-fail config-deps installation when such an env prefix exists in pnpm-lock.yaml.
Code

pacquet/crates/lockfile/src/env_lockfile.rs[R67-92]

+pub struct EnvLockfile {
+    pub lockfile_version: LockfileVersion<9>,
+
+    #[serde(default, serialize_with = "crate::serialize_yaml::sorted_map")]
+    pub importers: HashMap<String, EnvImporterSnapshot>,
+
+    #[serde(default, serialize_with = "crate::serialize_yaml::sorted_map")]
+    pub packages: HashMap<PackageKey, PackageMetadata>,
+
+    #[serde(default, serialize_with = "crate::serialize_yaml::sorted_map")]
+    pub snapshots: HashMap<PackageKey, SnapshotEntry>,
+}
+
+impl EnvLockfile {
+    /// The key used to refer to the root project inside `importers`.
+    pub const ROOT_IMPORTER_KEY: &str = ".";
+
+    /// A fresh, empty env lockfile with the root importer seeded.
+    /// Mirrors upstream's
+    /// [`createEnvLockfile`](https://github.com/pnpm/pnpm/blob/31858c544b/lockfile/fs/src/envLockfile.ts#L14-L24).
+    pub fn create() -> Self {
+        let mut importers = HashMap::new();
+        importers.insert(Self::ROOT_IMPORTER_KEY.to_string(), EnvImporterSnapshot::default());
+        EnvLockfile {
+            lockfile_version: LockfileVersion::<9>::try_from(ComVer::new(9, 0))
+                .expect("9.0 is compatible with lockfile major 9"),
Evidence
EnvLockfile hard-codes lockfile_version to LockfileVersion, which deserializes via ComVer that
only parses numeric major.minor strings; pnpm test fixtures in this repo demonstrate env-document
prefixes using lockfileVersion: env-1.0.

pacquet/crates/lockfile/src/env_lockfile.rs[65-93]
pacquet/crates/lockfile/src/comver.rs[34-40]
lockfile/fs/test/write.test.ts[311-314]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`EnvLockfile` currently deserializes `lockfileVersion` via `LockfileVersion<9>` (which in turn deserializes via `ComVer`), meaning it only accepts numeric `"<major>.<minor>"` strings. Repos/fixtures may contain an env document whose `lockfileVersion` is a non-numeric string (e.g. `env-1.0`), causing `EnvLockfile::read()` to fail and breaking config-deps installs.
## Issue Context
The repo itself contains pnpm fixtures/tests that model an env-document prefix with `lockfileVersion: env-1.0`. Pacquet's env-installer reads and parses the env document via `EnvLockfile::read`, so this incompatibility becomes an install-blocking failure.
## Fix Focus Areas
- pacquet/crates/lockfile/src/env_lockfile.rs[65-127]
- pacquet/crates/lockfile/src/comver.rs[34-49]
## Suggested fix approach
- Change `EnvLockfile.lockfile_version` to deserialize from either:
- a numeric comver string (current behavior), or
- the legacy/non-numeric env-doc marker string(s) (e.g. `env-1.0`), mapping them to a compatible internal representation.
- Consider preserving the original env-doc `lockfileVersion` string when writing back (if round-trip fidelity matters), or consistently normalizing to the numeric lockfile version if pnpm accepts it.
- Add a unit test covering `EnvLockfile::read()` successfully parsing an env document whose `lockfileVersion` is `env-1.0`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Object-form migration fails 🐞 Bug ≡ Correctness
Description
resolve_and_install_config_deps treats ConfigDependency::Detailed.integrity as a
"<version>+<integrity>" string and calls parse_integrity, but the Detailed form’s integrity is
checksum-only, so valid object-form configDependencies will always fail migration with
ERR_PNPM_CONFIG_DEP_NO_INTEGRITY. This breaks backward compatibility for workspaces using the `{
tarball?, integrity }` form.
Code

pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[R43-59]

+            ConfigDependency::Detailed(detail) => {
+                if !has_config_dep(&env_lockfile, name) {
+                    let (version, integrity) = parse_integrity(name, &detail.integrity)?;
+                    let registry = opts.pick_registry(name);
+                    let tarball = detail
+                        .tarball
+                        .clone()
+                        .unwrap_or_else(|| npm_tarball_url(name, &version, registry));
+                    migrate_into_lockfile(
+                        &mut env_lockfile,
+                        name,
+                        &version,
+                        integrity,
+                        tarball,
+                        registry,
+                    );
+                    lockfile_changed = true;
Evidence
The PR’s migration path calls parse_integrity on detail.integrity, but the object-form stores
checksum-only integrity, and parse_integrity requires version+integrity, so the migration will
always error for valid object-form inputs.

pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[41-60]
pacquet/crates/workspace-state/src/lib.rs[50-69]
pacquet/crates/env-installer/src/parse_integrity.rs[30-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ConfigDependency::Detailed` migration incorrectly calls `parse_integrity(name, &detail.integrity)`, which expects `"<version>+<integrity>"`. In the object form, `detail.integrity` is checksum-only (e.g. `sha512-...`), so migration errors even for valid inputs.
## Issue Context
- `ConfigDependencyDetail` stores `{ tarball?: String, integrity: String }` where `integrity` is a checksum, not a combined version+checksum string.
- `parse_integrity` hard-requires a `+` separator and returns `NoIntegrity` if missing.
## Fix Focus Areas
- pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[41-61]
- pacquet/crates/env-installer/src/parse_integrity.rs[30-51]
### Implementation notes
- For `ConfigDependency::Detailed(detail)`:
- Parse integrity as `ssri::Integrity` directly from `detail.integrity` (no `split_once('+')`).
- Determine the version to use for the lockfile `PackageKey` / GVS path:
- If `detail.tarball` is present, extract the version (either by parsing canonical npm tarball URLs, or by downloading/extracting `package.json` to read `version`).
- If the version cannot be determined, surface `ERR_PNPM_BAD_CONFIG_DEP` with a clear message (don’t call `parse_integrity`).
- Migrate into the env lockfile with that `(version, integrity, tarball)`.
- Add/extend a unit/integration test that covers object-form migration end-to-end.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Removed config deps persist ✓ Resolved 🐞 Bug ≡ Correctness
Description
resolve_and_install_config_deps only inserts/updates env-lockfile
importers['.'].configDependencies entries for currently declared config deps and never removes
entries that were deleted from pnpm-workspace.yaml. As a result, deleted config deps remain in the
env lockfile and continue to be installed/kept under .pnpm-config.
Code

pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[R34-110]

+    let mut env_lockfile = EnvLockfile::read(opts.root_dir)
+        .map_err(ConfigDepError::ReadLockfile)?
+        .unwrap_or_else(EnvLockfile::create);
+
+    let mut to_resolve: Vec<(String, String)> = Vec::new();
+    let mut lockfile_changed = false;
+
+    for (name, value) in config_deps {
+        match value {
+            ConfigDependency::Detailed(detail) => {
+                if !has_config_dep(&env_lockfile, name) {
+                    let (version, integrity) = parse_integrity(name, &detail.integrity)?;
+                    let registry = opts.pick_registry(name);
+                    let tarball = detail
+                        .tarball
+                        .clone()
+                        .unwrap_or_else(|| npm_tarball_url(name, &version, registry));
+                    migrate_into_lockfile(
+                        &mut env_lockfile,
+                        name,
+                        &version,
+                        integrity,
+                        tarball,
+                        registry,
+                    );
+                    lockfile_changed = true;
+                }
+            }
+            ConfigDependency::VersionWithIntegrity(value) if value.contains('+') => {
+                if !has_config_dep(&env_lockfile, name) {
+                    let (version, integrity) = parse_integrity(name, value)?;
+                    let registry = opts.pick_registry(name);
+                    let tarball = npm_tarball_url(name, &version, registry);
+                    migrate_into_lockfile(
+                        &mut env_lockfile,
+                        name,
+                        &version,
+                        integrity,
+                        tarball,
+                        registry,
+                    );
+                    lockfile_changed = true;
+                }
+            }
+            ConfigDependency::VersionWithIntegrity(specifier) => {
+                if let Some(existing) = config_dep(&env_lockfile, name)
+                    && existing.specifier == *specifier
+                    && env_lockfile.packages.contains_key(&pkg_key(name, &existing.version)?)
+                {
+                    continue;
+                }
+                to_resolve.push((name.clone(), specifier.clone()));
+            }
+        }
+    }
+
+    if opts.frozen_lockfile && (lockfile_changed || !to_resolve.is_empty()) {
+        return Err(ConfigDepError::FrozenLockfileOutdated {
+            message: r#"Cannot update configDependencies with "frozen-lockfile" because the lockfile is not up to date"#.to_string(),
+        });
+    }
+
+    if to_resolve.is_empty() {
+        if lockfile_changed {
+            env_lockfile.write(opts.root_dir).map_err(ConfigDepError::WriteLockfile)?;
+        }
+        return install_config_deps::<Reporter>(&env_lockfile, opts).await;
+    }
+
+    for (name, specifier) in &to_resolve {
+        resolve_one(&mut env_lockfile, resolver, opts, name, specifier).await?;
+    }
+
+    prune_env_lockfile(&mut env_lockfile);
+    env_lockfile.write(opts.root_dir).map_err(ConfigDepError::WriteLockfile)?;
+    install_config_deps::<Reporter>(&env_lockfile, opts).await
+}
Evidence
The resolver loop only processes currently declared config deps and does not remove stale importer
keys; the installer then normalizes/install based on importer.configDependencies, so stale entries
remain installed. The pruner only touches packages/snapshots, not importer keys.

pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[34-110]
pacquet/crates/env-installer/src/prune.rs[14-48]
pacquet/crates/env-installer/src/install_config_deps.rs[338-396]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When a config dependency is removed from `pnpm-workspace.yaml`, the env lockfile’s `importers['.'].configDependencies` retains the stale entry because `resolve_and_install_config_deps` never removes keys that are no longer declared. The install step then continues to act on the stale env-lockfile set.
## Issue Context
- `install_config_deps` installs based on `env_lockfile.importers['.'].configDependencies`.
- `prune_env_lockfile` only prunes `packages`/`snapshots`, not importer keys.
## Fix Focus Areas
- pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[34-110]
- pacquet/crates/env-installer/src/prune.rs[14-48]
- pacquet/crates/env-installer/src/install_config_deps.rs[338-395]
### Implementation notes
- After reading/creating `env_lockfile`, reconcile importer keys with `config_deps`:
- `let importer = env_lockfile.root_importer_mut();`
- `importer.config_dependencies.retain(|name, _| config_deps.contains_key(name));`
- If any were removed, set `lockfile_changed = true`.
- If `lockfile_changed` is true (including removals), ensure you:
- call `prune_env_lockfile(&mut env_lockfile)` even when `to_resolve.is_empty()`,
- write the env lockfile,
- then proceed to `install_config_deps` so `.pnpm-config` is pruned accordingly.
- Add a test that removes a config dep and asserts the env lockfile and `.pnpm-config` no longer contain it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Symlink cleanup uses remove_dir_all ✓ Resolved 🐞 Bug ☼ Reliability
Description
env-installer prunes stale .pnpm-config entries and optional-subdep sibling links with
fs::remove_dir_all, even though these paths are created as directory symlinks/junctions. This can
fail to remove links correctly across platforms and is risky compared to unlinking via
pacquet_fs::remove_symlink_dir (the helper explicitly designed for this).
Code

pacquet/crates/env-installer/src/install_config_deps.rs[R50-55]

+    // Drop config deps that are no longer declared.
+    for name in &existing {
+        if !normalized.contains_key(name) {
+            started.report::<Reporter>();
+            let _ = fs::remove_dir_all(config_modules_dir.join(name));
+        }
Evidence
The code creates .pnpm-config entries as directory symlinks/junctions via
pacquet_fs::force_symlink_dir, but removes stale entries via fs::remove_dir_all. The repo
already provides pacquet_fs::remove_symlink_dir specifically because directory symlinks/junctions
need platform-specific unlinking (remove_file on Unix vs remove_dir on Windows), which
remove_dir_all is not meant to handle.

pacquet/crates/env-installer/src/install_config_deps.rs[50-55]
pacquet/crates/env-installer/src/install_config_deps.rs[115-123]
pacquet/crates/env-installer/src/install_config_deps.rs[235-239]
pacquet/crates/fs/src/symlink_dir.rs[93-108]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pacquet-env-installer` removes stale config-dep links using `fs::remove_dir_all(...)` even though those entries are created as directory symlinks/junctions (`pacquet_fs::force_symlink_dir`). Recursive directory deletion is the wrong primitive for unlinking and may fail or behave unexpectedly depending on platform and link type.
### Issue Context
Config deps are linked into `node_modules/.pnpm-config/<name>` and optional subdeps are linked as siblings under the parent leaf `node_modules`. Both should be pruned by unlinking the link itself (symlink/junction), not by recursive directory deletion.
### Fix Focus Areas
- pacquet/crates/env-installer/src/install_config_deps.rs[50-55]
- pacquet/crates/env-installer/src/install_config_deps.rs[235-239]
### Suggested fix
- Replace `fs::remove_dir_all(...)` in both prune loops with logic that:
- attempts to unlink using `pacquet_fs::remove_symlink_dir(path)` when the entry is a symlink/junction,
- otherwise falls back to `fs::remove_dir_all(path)` for real directories,
- and does not silently swallow errors that indicate real filesystem problems (at least log/debug-report them).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Tarball URL ignores scope registry ✓ Resolved 🐞 Bug ≡ Correctness
Description
When normalizing the env lockfile for install, registry-form resolutions (integrity-only) derive
tarball URLs using only the default registry string. This ignores
ConfigDepsInstallOptions::registries per-scope (@scope) entries and can make scoped config deps
download from the wrong registry host/path.
Code

pacquet/crates/env-installer/src/install_config_deps.rs[R342-370]

+fn normalize_from_lockfile(
+    env_lockfile: &EnvLockfile,
+    default_registry: &str,
+) -> Result<BTreeMap<String, NormalizedConfigDep>, ConfigDepError> {
+    let mut deps = BTreeMap::new();
+    let Some(importer) = env_lockfile.importers.get(EnvLockfile::ROOT_IMPORTER_KEY) else {
+        return Ok(deps);
+    };
+    for (name, spec) in &importer.config_dependencies {
+        let pkg_key = format!("{name}@{}", spec.version);
+        let key = pkg_key.parse().map_err(|_| ConfigDepError::EnvLockfileCorrupted {
+            message: format!(
+                "pnpm-lock.yaml has an unparsable config-dependency key \"{pkg_key}\"",
+            ),
+        })?;
+        let pkg = env_lockfile.packages.get(&key).ok_or_else(|| {
+            ConfigDepError::EnvLockfileCorrupted {
+                message: format!(
+                    "pnpm-lock.yaml is corrupted or incomplete: missing packages entry for \
+                     \"{pkg_key}\" referenced from importers['.'].configDependencies",
+                ),
+            }
+        })?;
+        let (integrity, tarball) = integrity_and_tarball(
+            &pkg.resolution,
+            name,
+            &spec.version,
+            default_registry,
+        )
Evidence
The env-installer install path calls `normalize_from_lockfile(env_lockfile,
opts.default_registry())` and then uses that default registry to derive tarball URLs for
registry-form resolutions. However, the options type and its pick_registry method are explicitly
designed to support per-scope registries, so using only the default registry contradicts the
intended behavior.

pacquet/crates/env-installer/src/install_config_deps.rs[42-45]
pacquet/crates/env-installer/src/install_config_deps.rs[342-370]
pacquet/crates/env-installer/src/install_config_deps.rs[451-466]
pacquet/crates/env-installer/src/options.rs[18-59]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`normalize_from_lockfile(..., default_registry)` and `integrity_and_tarball(..., default_registry)` derive tarball URLs for `LockfileResolution::Registry` using the default registry for *all* packages. This breaks scoped packages that are served by a per-scope registry (`@scope` registry mapping), because the derived tarball URL is registry-dependent.
### Issue Context
`ConfigDepsInstallOptions` explicitly supports per-scope registry entries (`registries` + `pick_registry`). The resolve path uses `opts.pick_registry(name)` to decide the registry, but the install-normalization path ignores it.
### Fix Focus Areas
- pacquet/crates/env-installer/src/install_config_deps.rs[42-45]
- pacquet/crates/env-installer/src/install_config_deps.rs[342-384]
- pacquet/crates/env-installer/src/install_config_deps.rs[451-467]
### Suggested fix
- Change `normalize_from_lockfile` to take `opts: &ConfigDepsInstallOptions` (or a registry-picking callback) instead of a single `default_registry` string.
- For `LockfileResolution::Registry`, derive tarball URLs using `npm_tarball_url(name, version, opts.pick_registry(name))`.
- Apply the same logic for optional subdeps in `read_optional_subdeps` (use `opts.pick_registry(subdep_name)` when deriving tarball URLs for registry-form resolutions).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Missing config-deps Done event 🐞 Bug ◔ Observability
Description
install_config_deps emits pnpm:installing-config-deps Started when pruning stale links or
materializing missing packages, but it only emits Done when installed is non-empty. This can leave
an unmatched Started event despite real work occurring, contradicting the reporter's bracketing
contract.
Code

pacquet/crates/env-installer/src/install_config_deps.rs[R126-132]

+    if !installed.is_empty() {
+        Reporter::emit(&LogEvent::InstallingConfigDeps(InstallingConfigDepsLog {
+            level: LogLevel::Debug,
+            status: InstallingConfigDepsStatus::Done,
+            deps: installed,
+        }));
+    }
Evidence
StartedGate::report() is invoked for pruning and materialization, but Done is only emitted when
installed is non-empty; reporter documentation expects Started to be followed by a single Done
whenever the operation is not a no-op.

pacquet/crates/env-installer/src/install_config_deps.rs[46-56]
pacquet/crates/env-installer/src/install_config_deps.rs[83-85]
pacquet/crates/env-installer/src/install_config_deps.rs[126-132]
pacquet/crates/reporter/src/lib.rs[167-172]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`install_config_deps` can call `StartedGate::report()` (emitting the Started event) in several work-producing paths (pruning stale `.pnpm-config` entries; materializing missing packages), but the Done event is gated on `installed` being non-empty. This violates the documented contract that the channel is bracketed by Started then Done whenever work happens.
## Issue Context
The reporter docs state this channel should emit `started` then a single `done`, and only emit nothing for a true no-op. Current logic can do work without pushing to `installed`, which suppresses Done.
## Fix Focus Areas
- pacquet/crates/env-installer/src/install_config_deps.rs[46-56]
- pacquet/crates/env-installer/src/install_config_deps.rs[83-95]
- pacquet/crates/env-installer/src/install_config_deps.rs[126-133]
## Suggested fix approach
- Track whether `StartedGate` has emitted (`started.emitted`) and, if so, always emit a Done event at function end.
- Emit Done even when `deps` is empty (the payload supports omitting `deps` when empty).
- Optionally, consider adding entries to `deps` for cases where work occurred but no new symlink was created (e.g., materialization-only), if consumers rely on the list.
- Add a test that triggers Started via pruning or materialization but results in an empty `installed` list, and assert Done is still emitted.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Env-doc parsing LF-only 🐞 Bug ☼ Reliability
Description
extract_env_document only recognizes the exact LF-delimited markers "---\n" and "\n---\n", and
save_value_to_path relies on it to preserve the env document when rewriting pnpm-lock.yaml. If
pnpm-lock.yaml uses CRLF line endings or starts with a UTF-8 BOM, the env document will not be
detected and will be silently dropped on rewrite.
Code

pacquet/crates/lockfile/src/yaml_documents.rs[R42-59]

+/// Extract the env lockfile document (first YAML document) from a
+/// combined file. The synchronous counterpart to upstream's streaming
+/// [`streamReadFirstYamlDocument`](https://github.com/pnpm/pnpm/blob/31858c544b/lockfile/fs/src/yamlDocuments.ts#L15-L60):
+///
+/// - The file must begin with `---\n`; otherwise it carries no env
+///   document and this returns `None`.
+/// - Returns the slice between the leading `---\n` and the next
+///   `\n---\n` separator. A leading `---\n` with no following separator
+///   (an env-only file with no main document) also yields `None`,
+///   matching upstream's fall-through to `return null`.
+///
+/// pacquet reads the whole lockfile into memory rather than streaming,
+/// so this skips upstream's chunked BOM/CRLF handling — the only
+/// callers pass content pacquet itself wrote with LF line endings.
+pub fn extract_env_document(content: &str) -> Option<&str> {
+    let rest = content.strip_prefix(YAML_DOCUMENT_START)?;
+    rest.find(YAML_DOCUMENT_SEPARATOR).map(|idx| &rest[..idx])
+}
Evidence
The lockfile multi-document helpers hard-code LF-only document markers and explicitly skip BOM/CRLF
handling; both save_value_to_path (wanted lockfile writer) and EnvLockfile::read depend on
extract_env_document, so failed detection results in not preserving/reading the env document.

pacquet/crates/lockfile/src/yaml_documents.rs[13-59]
pacquet/crates/lockfile/src/save_lockfile.rs[75-90]
pacquet/crates/lockfile/src/env_lockfile.rs[113-126]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`extract_env_document` requires a literal `---\n` prefix and a `\n---\n` separator. This makes env-doc detection brittle: CRLF line endings and/or a UTF-8 BOM will cause `extract_env_document` to return `None`, and callers will treat the lockfile as having no env document.
## Issue Context
The env document is preserved when rewriting the wanted lockfile (`save_value_to_path`) and is also used by `EnvLockfile::read`. If detection fails, the env document can be lost during a rewrite.
## Fix Focus Areas
- pacquet/crates/lockfile/src/yaml_documents.rs[13-59]
- pacquet/crates/lockfile/src/save_lockfile.rs[75-90]
- pacquet/crates/lockfile/src/env_lockfile.rs[113-126]
### Suggested fix approach
- Make YAML document marker detection tolerant of:
- UTF-8 BOM (strip leading `\u{FEFF}` / 0xEFBBBF)
- CRLF: accept `---\r\n` and `\r\n---\r\n` in addition to the LF forms.
- Keep returned slices correct (avoid naive global string replacement that would invalidate indices). One pragmatic approach is:
- detect which newline style is present in the initial marker and search for the matching separator style.
- Apply the same tolerance to `extract_main_document` so preservation and reading stay consistent across line endings.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. No pruning when empty 🐞 Bug ☼ Reliability
Description
The CLI config-deps path returns early when config.config_dependencies is None or empty, so
removing the configDependencies block entirely will never trigger env-lockfile/.pnpm-config
cleanup. This leaves stale env-doc state and .pnpm-config links on disk after users delete all
config dependencies.
Code

pacquet/crates/cli/src/config_deps.rs[R38-49]

+pub async fn install_config_deps<Reporter: self::Reporter>(
+    config: &Config,
+    root_dir: &Path,
+    frozen_lockfile: bool,
+) -> Result<()> {
+    let Some(config_dependencies) = config.config_dependencies.as_ref() else {
+        return Ok(());
+    };
+    if config_dependencies.is_empty() {
+        return Ok(());
+    }
+    resolve_and_install::<Reporter>(config, config_dependencies, root_dir, frozen_lockfile).await
Evidence
The CLI explicitly treats missing/empty configDependencies as a no-op, which prevents any
env-installer reconciliation/pruning from running when users remove the block.

pacquet/crates/cli/src/config_deps.rs[34-50]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`install_config_deps` exits early when `config.config_dependencies` is `None` or empty, so the env-installer never runs and cannot prune stale env-lockfile entries or `.pnpm-config` links when the last config dependency is removed.
## Issue Context
- After this PR, config deps are installed/pruned by the env-installer.
- If the configDeps pipeline is skipped entirely, stale `.pnpm-config/*` and the env document can remain indefinitely.
## Fix Focus Areas
- pacquet/crates/cli/src/config_deps.rs[38-50]
### Implementation notes
- When `config.config_dependencies` is `None`/empty, consider running a lightweight cleanup path:
- If an env document exists (`EnvLockfile::read(root_dir)` returns `Some`) OR `node_modules/.pnpm-config` exists, invoke the env-installer with an empty `BTreeMap` so it can clear importer keys, prune packages/snapshots, write the env doc, and remove `.pnpm-config` entries.
- Otherwise keep the fast no-op.
- Add an integration test: install with one config dep, remove the `configDependencies` block, re-run install, assert `.pnpm-config/<dep>` is removed and env doc no longer lists it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (4)
10. Plus-version misclassified 🐞 Bug ≡ Correctness
Description
resolve_and_install_config_deps treats any configDependencies string containing '+' as the
legacy "+" format, so valid semver versions/specifiers containing build metadata (which include
'+') can be misrouted into parse_integrity and fail. This makes some otherwise valid
configDependency specs un-installable.
Code

pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[R62-66]

+            ConfigDependency::VersionWithIntegrity(value) if value.contains('+') => {
+                if !has_config_dep(&env_lockfile, name) {
+                    let (version, integrity) = parse_integrity(name, value)?;
+                    let registry = opts.pick_registry(name);
+                    let tarball = npm_tarball_url(name, &version, registry);
Evidence
The code branches on any '+' before parsing, while other parts of the repo include versions
containing + build metadata, and the lockfile URL builder explicitly strips build metadata after
+—showing + is a valid character in versions/specs.

pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[62-78]
pacquet/crates/env-installer/src/parse_integrity.rs[30-51]
pacquet/crates/exportable-manifest/src/tests.rs[33-37]
pacquet/crates/exportable-manifest/src/tests.rs[79-83]
pacquet/crates/lockfile/src/resolution.rs[320-332]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The migration branch `if value.contains('+')` assumes any `+` means legacy inline integrity (`<version>+<integrity>`). Semver build metadata also uses `+`, so a spec like `1.0.0+build.1` is misclassified and sent to `parse_integrity`, which then errors.
## Issue Context
- The repo already handles versions containing `+` elsewhere (build metadata).
- `parse_integrity` should only apply when the suffix is an actual SSRI integrity string.
## Fix Focus Areas
- pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs[62-78]
- pacquet/crates/env-installer/src/parse_integrity.rs[30-51]
### Implementation notes
- Replace `value.contains('+')` with a stricter check, e.g.:
- split once on `+` and only treat as legacy if the suffix parses as `ssri::Integrity` (or starts with a known algo prefix like `sha512-`).
- otherwise, treat the value as a clean specifier and resolve normally.
- Add a unit test using a configDependency specifier containing `+` build metadata to ensure it routes to the resolve path, not the migration path.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Catalogs read from wrong dir 🐞 Bug ≡ Correctness
Description
run_update_config_hooks searches ancestors for pnpm-workspace.yaml via
WorkspaceSettings::find_and_load, but then seeds hook catalogs by reading pnpm-workspace.yaml
only from root_dir. If the workspace manifest is found in an ancestor, catalogs will be missing
from the hook input and may not round-trip correctly.
Code

pacquet/crates/cli/src/config_deps.rs[R189-205]

+    let (base_dir, settings) = match WorkspaceSettings::find_and_load(root_dir).into_diagnostic()? {
+        Some((path, settings)) => {
+            (path.parent().map_or_else(|| root_dir.to_path_buf(), Path::to_path_buf), settings)
+        }
+        None => (root_dir.to_path_buf(), WorkspaceSettings::default()),
+    };
+    let mut input = serde_json::to_value(&settings)
+        .into_diagnostic()
+        .wrap_err("serialize workspace settings for updateConfig hooks")?;
+    // Seed the hook input with the catalogs read from the workspace
+    // manifest (`catalog:` + `catalogs:`), which `WorkspaceSettings`
+    // doesn't carry, so a hook can read and extend them.
+    let workspace_manifest =
+        pacquet_workspace::read_workspace_manifest(root_dir).into_diagnostic()?;
+    let yaml_catalogs = get_catalogs_from_workspace_manifest(workspace_manifest.as_ref())
+        .into_diagnostic()
+        .wrap_err("reading catalogs for updateConfig hooks")?;
Evidence
The code explicitly supports discovering pnpm-workspace.yaml in ancestor directories, but the
catalogs seeding path reads only from the immediate root_dir directory, which the workspace reader
does not search upward. This mismatch can drop catalogs from the hook input.

pacquet/crates/cli/src/config_deps.rs[189-205]
pacquet/crates/config/src/workspace_yaml.rs[612-644]
pacquet/crates/workspace/src/manifest.rs[109-121]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`run_update_config_hooks` derives `base_dir` from the location of the found `pnpm-workspace.yaml`, but it still reads the workspace manifest for catalogs from `root_dir`, which may not contain the manifest (because `find_and_load` walks ancestors).
### Issue Context
- `WorkspaceSettings::find_and_load(root_dir)` walks `root_dir.ancestors()`.
- `pacquet_workspace::read_workspace_manifest(dir)` reads only `dir/pnpm-workspace.yaml` and returns `None` if absent.
### Fix Focus Areas
- pacquet/crates/cli/src/config_deps.rs[189-205]
### Suggested fix
- Change `read_workspace_manifest(root_dir)` to `read_workspace_manifest(&base_dir)` (or use the `path` returned by `find_and_load` to get the manifest directory).
- This keeps workspace-settings discovery and catalogs seeding consistent when `pnpm-workspace.yaml` lives above the current directory.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Hook delta misses removed keys ✓ Resolved 🐞 Bug ≡ Correctness
Description
config_delta only iterates keys present in the hook output, so keys present in the input but
removed from the output are not included in the delta and therefore cannot be cleared via removal.
This also means a hook cannot remove catalogs (missing key is ignored by the catalogs capture
logic).
Code

pacquet/crates/cli/src/config_deps.rs[R252-267]

+/// The keys whose value the hooks changed between the serialized input
+/// config and the hooks' output. Applying only these avoids clobbering
+/// config resolved elsewhere (`.npmrc`, CLI flags) that a hook left
+/// untouched.
+fn config_delta(input: &Value, output: &Value) -> Value {
+    let (Some(input_obj), Some(output_obj)) = (input.as_object(), output.as_object()) else {
+        return output.clone();
+    };
+    let mut delta = serde_json::Map::new();
+    for (key, value) in output_obj {
+        if input_obj.get(key) != Some(value) {
+            delta.insert(key.clone(), value.clone());
+        }
+    }
+    Value::Object(delta)
+}
Evidence
config_delta’s loop only considers output_obj keys; it never checks for keys present in
input_obj and missing from output_obj. Separately, catalogs are only captured when the output
still contains a catalogs value, so deletion is ignored.

pacquet/crates/cli/src/config_deps.rs[227-239]
pacquet/crates/cli/src/config_deps.rs[252-267]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`config_delta` computes the set of changed keys by iterating only `output_obj`. Keys that existed in `input_obj` but are absent in `output_obj` are not represented, so a hook that removes a key cannot have that removal applied.
### Issue Context
This affects updateConfig semantics for key removals, and catalogs specifically: the code only updates `config.catalogs` when `current.get("catalogs")` is `Some(...)`, so deleting `catalogs` in a hook cannot clear it.
### Fix Focus Areas
- pacquet/crates/cli/src/config_deps.rs[227-239]
- pacquet/crates/cli/src/config_deps.rs[252-267]
### Suggested fix
- Update `config_delta` to consider the union of keys from both input and output, and include entries for keys removed from output.
- Decide on a clearing semantic for removed keys (e.g., treat removal as `null` for specific keys where clearing is supported, like `catalogs`, and explicitly set `config.catalogs = None` when `catalogs` is removed).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Config-deps resolver lacks named registries 🐞 Bug ≡ Correctness
Description
The config-deps install path constructs a bare NpmResolver with named_registries empty and does
not include the NamedRegistryResolver that the main install pipeline uses. Config dependencies
using named-registry aliases (e.g. gh:) will resolve in the main install path but fail in the
config-deps path.
Code

pacquet/crates/cli/src/config_deps.rs[R97-121]

+    let mut registries = HashMap::new();
+    registries.insert("default".to_string(), config.registry.clone());
+
+    let retry_opts = RetryOpts {
+        retries: config.fetch_retries,
+        factor: config.fetch_retry_factor,
+        min_timeout: Duration::from_millis(config.fetch_retry_mintimeout),
+        max_timeout: Duration::from_millis(config.fetch_retry_maxtimeout),
+    };
+
+    let resolver = NpmResolver {
+        registries: registries.clone(),
+        named_registries: HashMap::new(),
+        http_client: Arc::clone(&http_client),
+        auth_headers: Arc::clone(&config.auth_headers),
+        meta_cache: Arc::new(InMemoryPackageMetaCache::default()),
+        fetch_locker: shared_packument_fetch_locker(),
+        picked_manifest_cache: shared_picked_manifest_cache(),
+        cache_dir: Some(config.cache_dir.clone()),
+        offline: config.offline,
+        prefer_offline: config.prefer_offline,
+        ignore_missing_time_field: config.minimum_release_age_ignore_missing_time,
+        full_metadata: false,
+        retry_opts,
+    };
Evidence
In the config-deps path, NpmResolver is built with named_registries empty. In contrast, the main
install path merges config.named_registries and constructs a NamedRegistryResolver in the
resolver chain, enabling named-registry alias resolution.

pacquet/crates/cli/src/config_deps.rs[97-121]
pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs[486-499]
pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs[655-691]
pacquet/crates/config/src/lib.rs[620-635]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Config-deps resolution builds only an `NpmResolver` and sets `named_registries: HashMap::new()`. The main install path merges `config.named_registries` and wires a `NamedRegistryResolver` into the resolver chain. This asymmetry means `configDependencies` cannot use the same specifier surface area as normal dependencies.
### Issue Context
The main install path explicitly supports named registries by merging user-provided aliases and adding a `NamedRegistryResolver` to the `DefaultResolver` chain.
### Fix Focus Areas
- pacquet/crates/cli/src/config_deps.rs[97-121]
- pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs[486-502]
- pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs[655-691]
### Suggested fix
- Either:
- build the same `DefaultResolver` chain used by the main install (at least including `NamedRegistryResolver` and merged named registries), or
- explicitly document/validate that configDependencies only allow npm-registry specifiers and reject named-registry alias specifiers early.
- At minimum, pass merged named registries instead of `HashMap::new()` if/when the chain is extended.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

feat(pacquet): end-to-end support for pnpm-style configDependencies
✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Walkthroughs

Description
• Resolve and install workspace configDependencies into node_modules/.pnpm-config before main
  install.
• Persist config deps in the env lockfile (first pnpm-lock.yaml document) and preserve it on
  rewrites.
• Add updateConfig pnpmfile hook chaining (plugin + root pnpmfile) and support pacquet add --config.
Diagram
graph TD
  A["pacquet CLI"] --> B["Config finalization"] --> C["Env installer"] --> D["Env lockfile"] --> E["pnpm-lock.yaml"]
  B --> F["updateConfig hooks"] --> G["Config (mutated)"] --> H["Package manager install"] --> E
  C --> I[("Global virtual store")]
  C --> J[".pnpm-config links"]
  subgraph Legend
    direction LR
    _svc[Service/Module] ~~~ _db[(Data/Store)]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Avoid leaking StoreDir by changing installer APIs
  • ➕ Eliminates intentional memory leak in CLI config-deps path
  • ➕ Makes long-running embedding (if any) safer
  • ➖ Requires broader refactor of tarball/store APIs currently expecting &'static StoreDir
  • ➖ Higher churn across installer and download/import plumbing
2. Integrate config-deps install into package-manager pipeline
  • ➕ Single orchestrator for all install phases
  • ➕ Potentially cleaner ownership of shared install handles (client/store/retry opts)
  • ➖ Harder to ensure env lockfile lands before wanted lockfile is loaded/rewritten
  • ➖ Blurs the “config-finalization” seam that matches pnpm behavior
3. Persist hook delta as an explicit typed structure
  • ➕ More controlled surface than JSON round-trip for updateConfig
  • ➕ Can validate/limit what hooks can mutate
  • ➖ Less parity with pnpm’s hook contract (which is object-based)
  • ➖ More work to maintain and evolve as pnpm adds fields

Recommendation: The PR’s approach (a dedicated env-installer + explicit config-finalization phase + JSON round-trip through WorkspaceSettings) is the best fit for pnpm parity and for ensuring the env document is written before the main lockfile path runs. The main follow-up to consider is removing the StoreDir leak by evolving downstream APIs to accept non-'static borrows once the surrounding installer plumbing is ready for that refactor.

Grey Divider

File Changes

Enhancement (29)
Cargo.toml Add pacquet-env-installer crate to workspace +1/-0

Add pacquet-env-installer crate to workspace

• Registers the new env-installer crate in the workspace members/dependencies so it can be consumed by CLI and other crates.

Cargo.toml


cli_args.rs Run config-dep install + updateConfig hooks before State::init +35/-5

Run config-dep install + updateConfig hooks before State::init

• Moves config-dependency resolution/installation and updateConfig hook execution into the install command path before the main install state is initialized, ensuring the env lockfile is written first and Config mutations are applied in time.

pacquet/crates/cli/src/cli_args.rs


add.rs Add --config flag to route pacquet add to configDependencies +39/-2

Add --config flag to route pacquet add to configDependencies

• Introduces 'pacquet add --config' to resolve/install a config dependency into '.pnpm-config', then persist the clean specifier to 'pnpm-workspace.yaml'. Validates package naming and defaults to 'latest' when no version is provided.

pacquet/crates/cli/src/cli_args/add.rs


config_deps.rs Implement config-deps install and updateConfig hook runner +283/-0

Implement config-deps install and updateConfig hook runner

• Adds the orchestration layer to (1) resolve/install declared configDependencies via pacquet-env-installer and (2) load and chain updateConfig hooks from plugin pnpmfiles under '.pnpm-config' plus the workspace pnpmfile. Applies only hook-changed WorkspaceSettings keys back onto Config and captures hook-modified catalogs.

pacquet/crates/cli/src/config_deps.rs


lib.rs Expose config_deps module +1/-0

Expose config_deps module

• Registers the new CLI module that handles configDependencies installation and updateConfig hook execution.

pacquet/crates/cli/src/lib.rs


lib.rs Enable Config round-trip for updateConfig + store hook-injected catalogs +35/-5

Enable Config round-trip for updateConfig + store hook-injected catalogs

• Adds Serialize support to config enums/fields needed for WorkspaceSettings JSON round-trips, implements custom Serialize for mixed-type settings, and adds 'Config::catalogs' to carry catalogs produced by updateConfig hooks.

pacquet/crates/config/src/lib.rs


workspace_yaml.rs Make WorkspaceSettings serializable +1/-1

Make WorkspaceSettings serializable

• Derives Serialize for WorkspaceSettings so it can be converted to/from JSON for updateConfig hook input/output and then applied back to Config.

pacquet/crates/config/src/workspace_yaml.rs


Cargo.toml Introduce pacquet-env-installer crate +46/-0

Introduce pacquet-env-installer crate

• Adds a new crate with dependencies on config, lockfile, graph-hasher, network, package-manager import, and workspace-state to implement pnpm-style env-installer behavior for configDependencies.

pacquet/crates/env-installer/Cargo.toml


errors.rs Add pnpm-compatible error types/codes for config-deps +80/-0

Add pnpm-compatible error types/codes for config-deps

• Defines ConfigDepError with user-facing diagnostic codes matching pnpm’s ERR_PNPM_* contract, covering integrity migration failures, optional-subdep exact-version enforcement, lockfile corruption, and frozen-lockfile rejection paths.

pacquet/crates/env-installer/src/errors.rs


install_config_deps.rs Install config deps into .pnpm-config via global virtual store +531/-0

Install config deps into .pnpm-config via global virtual store

• Implements materialization of config deps into the global virtual store and symlinking under 'node_modules/.pnpm-config', including pruning stale links and installing one level of optional subdeps with host platform filtering and debug logging.

pacquet/crates/env-installer/src/install_config_deps.rs


lib.rs Export env-installer public API surface +34/-0

Export env-installer public API surface

• Provides crate-level documentation and re-exports for resolve/install, lockfile pruning, integrity parsing, and install primitives used by the CLI.

pacquet/crates/env-installer/src/lib.rs


options.rs Define ConfigDepsInstallOptions and registry selection logic +72/-0

Define ConfigDepsInstallOptions and registry selection logic

• Adds a borrowed options struct carrying install handles/settings (root dir, store, client, registries, retry/frozen flags, platform info) plus scoped-registry selection mirroring pnpm.

pacquet/crates/env-installer/src/options.rs


parse_integrity.rs Parse old inline version+integrity config-dep format +51/-0

Parse old inline version+integrity config-dep format

• Implements migration parsing for legacy '<version>+<integrity>' entries and defines normalized in-memory shapes consumed by the install pass.

pacquet/crates/env-installer/src/parse_integrity.rs


prune.rs Prune unreachable env-lockfile packages/snapshots +48/-0

Prune unreachable env-lockfile packages/snapshots

• Keeps the env document minimal by retaining only packages/snapshots reachable from configDependencies and one level of optional subdeps, removing stale entries after re-resolve.

pacquet/crates/env-installer/src/prune.rs


resolve_and_install_config_deps.rs Resolve clean specifiers, migrate old formats, then install +230/-0

Resolve clean specifiers, migrate old formats, then install

• Implements the end-to-end flow: reads/creates env lockfile, migrates old formats into lockfile, resolves clean specifiers when missing/outdated, enforces frozen-lockfile semantics, prunes stale entries, writes the env document, and triggers installation/linking.

pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs


resolve_optional_subdeps.rs Resolve optionalDependencies one level deep with exact-version enforcement +154/-0

Resolve optionalDependencies one level deep with exact-version enforcement

• Resolves and records optional subdeps for config deps into the env lockfile, capturing platform fields (os/cpu/libc) and rejecting non-exact optional versions to preserve reproducibility.

pacquet/crates/env-installer/src/resolve_optional_subdeps.rs


global_virtual_store_path.rs Add GVS path hashing for leaf nodes and config-dep subdeps +47/-2

Add GVS path hashing for leaf nodes and config-dep subdeps

• Introduces 'calc_leaf_global_virtual_store_path' and 'calc_global_virtual_store_path_with_subdeps' to hash config deps (and their optional subdeps) into stable global-virtual-store paths matching pnpm’s semantics.

pacquet/crates/graph-hasher/src/global_virtual_store_path.rs


lib.rs Re-export new GVS hashing functions +4/-1

Re-export new GVS hashing functions

• Exports the leaf and subdeps-aware GVS path helpers for use by env-installer and other crates.

pacquet/crates/graph-hasher/src/lib.rs


finder.rs Add plugin pnpmfile discovery for configDependencies +59/-1

Add plugin pnpmfile discovery for configDependencies

• Implements pnpm plugin name detection and resolves pnpmfile paths for installed plugin config deps under '.pnpm-config', preferring pnpmfile.mjs over pnpmfile.cjs and skipping missing plugin directories.

pacquet/crates/hooks/src/finder.rs


lib.rs Add updateConfig hook to PnpmfileHooks trait +16/-0

Add updateConfig hook to PnpmfileHooks trait

• Extends the hook trait with an async updateConfig method (default no-op), enabling hooks to transform configuration before install.

pacquet/crates/hooks/src/lib.rs


node_runtime.rs Dispatch updateConfig to Node worker and normalize null result +12/-0

Dispatch updateConfig to Node worker and normalize null result

• Implements updateConfig execution in NodeJsHooks via the worker bridge and treats a null worker response as “no hook”, returning the original config unchanged.

pacquet/crates/hooks/src/node_runtime.rs


env_lockfile.rs Introduce EnvLockfile multi-document read/write support +149/-0

Introduce EnvLockfile multi-document read/write support

• Adds a typed representation of the env lockfile (first YAML document) with read/write that preserves the main lockfile document and guarantees the root importer exists, matching pnpm’s envLockfile behavior.

pacquet/crates/lockfile/src/env_lockfile.rs


lib.rs Export env_lockfile module from lockfile crate +2/-0

Export env_lockfile module from lockfile crate

• Registers and re-exports EnvLockfile and related types so other crates (env-installer, CLI) can consume them.

pacquet/crates/lockfile/src/lib.rs


resolution.rs Expose npm_tarball_url for env-installer migration/resolution +1/-1

Expose npm_tarball_url for env-installer migration/resolution

• Makes 'npm_tarball_url' public so env-installer can derive canonical tarball URLs when lockfile resolutions omit tarball fields.

pacquet/crates/lockfile/src/resolution.rs


yaml_documents.rs Add extract_env_document helper for multi-doc pnpm-lock.yaml +21/-2

Add extract_env_document helper for multi-doc pnpm-lock.yaml

• Exposes YAML document framing constants internally and adds 'extract_env_document' to retrieve the first YAML document when present, enabling env preservation during main lockfile rewrites.

pacquet/crates/lockfile/src/yaml_documents.rs


install.rs Prefer hook-injected catalogs over workspace manifest catalogs +10/-2

Prefer hook-injected catalogs over workspace manifest catalogs

• Switches catalog selection to use 'config.catalogs' when set by updateConfig hooks, falling back to reading from pnpm-workspace.yaml only when hooks did not modify catalogs.

pacquet/crates/package-manager/src/install.rs


lib.rs Add pnpm:installing-config-deps reporter event +42/-0

Add pnpm:installing-config-deps reporter event

• Introduces a new structured log event to bracket config-deps installation ('started'/'done'), including the installed '{name, version}' list and suppressing output on no-op runs.

pacquet/crates/reporter/src/lib.rs


edit.rs Add format-preserving edits for configDependencies block +58/-5

Add format-preserving edits for configDependencies block

• Implements insertion/upsert of 'configDependencies' entries while preserving YAML formatting/comments, reusing existing splice helpers generalized to accept explicit mapping paths.

pacquet/crates/workspace-manifest-writer/src/edit.rs


lib.rs Expose set_config_dependency API for pnpm-workspace.yaml +28/-0

Expose set_config_dependency API for pnpm-workspace.yaml

• Adds a public helper to write/update 'configDependencies' in pnpm-workspace.yaml (creating file/block if needed) while preserving formatting, used by 'pacquet add --config'.

pacquet/crates/workspace-manifest-writer/src/lib.rs


Bug fix (1)
save_lockfile.rs Preserve env lockfile document when writing wanted lockfile +27/-3

Preserve env lockfile document when writing wanted lockfile

• Updates lockfile save logic to detect and re-prepend an existing env document before writing the main lockfile YAML, matching pnpm’s multi-document wanted lockfile write semantics.

pacquet/crates/lockfile/src/save_lockfile.rs


Tests (7)
config_dependencies.rs Add CLI e2e tests for configDependencies and updateConfig behavior +171/-0

Add CLI e2e tests for configDependencies and updateConfig behavior

• Covers 'pacquet install' installing/linking config deps and writing env lockfile, repeat installs being no-ops, updateConfig mutating nodeLinker before install, updateConfig injecting catalogs used to resolve 'catalog:' deps, and 'pacquet add --config' editing pnpm-workspace.yaml while preserving existing keys.

pacquet/crates/cli/tests/config_dependencies.rs


tests.rs Add integration/unit coverage for env-installer behaviors +463/-0

Add integration/unit coverage for env-installer behaviors

• Tests resolving/installing with and without existing env lockfile, optional subdeps and platform metadata, frozen-lockfile failure/success cases, legacy format migration, pruning behavior, and reporter event emission only when work is performed.

pacquet/crates/env-installer/src/tests.rs


tests.rs Test new GVS hashing helpers for parity and collision prevention +63/-2

Test new GVS hashing helpers for parity and collision prevention

• Adds tests ensuring leaf hashing matches single-node graph hashing, empty-subdeps hashing equals leaf hashing, and subdep identity changes partition the parent path.

pacquet/crates/graph-hasher/src/global_virtual_store_path/tests.rs


node_hooks.rs Test plugin detection/path resolution and updateConfig behavior +82/-0

Test plugin detection/path resolution and updateConfig behavior

• Adds unit tests for plugin name patterns, plugin pnpmfile path calculation ordering and mjs preference, and updateConfig hook application/no-op semantics.

pacquet/crates/hooks/tests/node_hooks.rs


tests.rs Test env document round-trip and main-document preservation +103/-0

Test env document round-trip and main-document preservation

• Verifies env doc write/read equality, preservation of pre-existing main lockfile content, and preservation of env doc when the main lockfile is re-saved by the normal install flow.

pacquet/crates/lockfile/src/env_lockfile/tests.rs


tests.rs Update test config initialization for new Config::catalogs field +1/-0

Update test config initialization for new Config::catalogs field

• Adjusts test helper config construction to include the new 'catalogs: None' field.

pacquet/crates/package-manager/src/install_package_from_registry/tests.rs


tests.rs Test configDependencies writer behavior and preservation +41/-0

Test configDependencies writer behavior and preservation

• Adds tests ensuring the block is created when absent, entries are added/upserted correctly, and unrelated keys/comments remain intact.

pacquet/crates/workspace-manifest-writer/src/tests.rs


Other (2)
Cargo.toml Wire CLI dependencies for config deps + hooks + manifest writing +8/-1

Wire CLI dependencies for config deps + hooks + manifest writing

• Adds new crate dependencies needed to resolve/install config deps, run updateConfig hooks, compute GVS paths, and edit pnpm-workspace.yaml. Adjusts dev-dependencies accordingly.

pacquet/crates/cli/Cargo.toml


Cargo.toml Add catalogs types dependency to Config +1/-0

Add catalogs types dependency to Config

• Introduces 'pacquet-catalogs-types' so Config can store catalogs injected by updateConfig hooks.

pacquet/crates/config/Cargo.toml


Grey Divider

Qodo Logo

Comment thread pacquet/crates/env-installer/src/install_config_deps.rs
Comment thread pacquet/crates/env-installer/src/install_config_deps.rs
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e2b0afb

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (1)
pacquet/crates/cli/tests/config_dependencies.rs (1)

37-44: ⚡ Quick win

Add pre-assert diagnostics for non-assert_eq! checks.

Several assert! checks run without nearby logging of the inspected values/paths, which makes CI failures harder to debug.

As per coding guidelines, "Follow test-logging guidance — log before non-assert_eq! assertions, dbg! complex structures, skip logging for simple scalar assert_eq!."

Also applies to: 66-69, 98-102, 128-137, 165-168

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pacquet/crates/cli/tests/config_dependencies.rs` around lines 37 - 44, Add
diagnostic logging before each non-assert_eq! assertion so CI failures show
inspected values: e.g., print the computed workspace path and the `installed`
path and the `lockfile` contents before the assert! calls in
config_dependencies.rs (around the checks using variables `installed`,
`lockfile`, and `workspace`), using dbg! or eprintln! with clear labels; apply
the same pattern to the other non-assert_eq! blocks noted (the checks around the
ranges that reference the pnpm lockfile, `configDependencies`, and package
names) so tests emit the values being checked just prior to assertion.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pacquet/crates/cli/src/cli_args.rs`:
- Around line 265-299: The config-deps phase is using the CLI cwd (`dir`)
instead of the workspace/lockfile root, so change calls to
config_deps::install_config_deps and config_deps::run_update_config_hooks to use
the same root that State::init uses (i.e. use
cfg.workspace_dir.as_deref().unwrap_or(dir.as_path()) as the directory argument)
rather than `&dir`, keeping the rest of the call sites (reporter generics and
frozen_lockfile) unchanged so workspace-level `.pnpm-config` and env lockfile
are read/written consistently with State::init before calling args.run.

In `@pacquet/crates/cli/src/cli_args/add.rs`:
- Around line 131-140: The code computes root_dir from
state.manifest.path().parent() which picks the package directory; instead
resolve --config against the workspace root by preferring
state.config.workspace_dir.as_deref() and only falling back to the manifest
parent (or "."), then pass that root_dir into
config_deps::add_config_dependency::<Reporter>; update the root_dir construction
to use state.config.workspace_dir.as_deref().map_or_else(||
state.manifest.path().parent().map_or_else(|| PathBuf::from("."),
Path::to_path_buf), |ws| PathBuf::from(ws)) so configDependencies are
stored/modified at the workspace level.

In `@pacquet/crates/cli/src/config_deps.rs`:
- Around line 227-266: The hook-output handling currently ignores keys removed
by hooks (e.g., current.get("catalogs") skip and config_delta omits missing
keys), so fixes must: (1) when detecting current.get("catalogs") !=
input.get("catalogs") and the output has removed "catalogs", explicitly set
config.catalogs to an empty catalog representation (e.g., Some(empty Vec/Map)
instead of leaving the manifest value) via the same code path that assigns
config.catalogs (update the block that references current.get("catalogs") and
config.catalogs); and (2) change config_delta(input, output) to include keys
that existed in input but are absent in output by inserting a sentinel
(Value::Null) for general deleted keys and special-case "catalogs" to insert the
explicit empty catalogs Value so delta_settings (deserialized into
WorkspaceSettings) and delta_settings.apply_to(config, &base_dir) can clear
fields rather than leaving old values. Ensure you reference and update the
functions/variables config_delta, current.get("catalogs"), config.catalogs,
delta_settings, and WorkspaceSettings::apply_to.

In `@pacquet/crates/env-installer/src/errors.rs`:
- Around line 58-60: DownloadTarball currently annotates a crate-local
diagnostic code which overrides codes from the inner TarballError; remove the
variant-level #[diagnostic(code(...))] (or otherwise stop assigning a new
diagnostic code) on the DownloadTarball enum variant so the underlying
TarballError's diagnostic code/messages are preserved and surfaced to callers;
keep the display message and the source wrapper
(DownloadTarball(#[error(source)] TarballError)) intact so only the
diagnostic-code override is removed.

In `@pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs`:
- Around line 183-184: The code silently ignores pkg_key parse failures (let
Ok(key) = pkg_key(name, version) else { return }) which aborts migration without
signaling an error; change the function to return a Result and replace the
pattern with let key = pkg_key(name, version)? so failures propagate, then
update callers (the places that invoke this routine around the earlier call
sites referenced) to use the ? operator (as suggested for the call sites at
lines 51 and 67) so errors bubble up instead of being swallowed before inserting
into env_lockfile.root_importer_mut().config_dependencies.

In `@pacquet/crates/env-installer/src/tests.rs`:
- Around line 391-396: The test currently checks CONFIG_DEP_EVENTS (locked via
CONFIG_DEP_EVENTS.lock().unwrap()) using contains(), which allows wrong orders
or duplicates; replace the loose check with a strict order-and-count assertion
by comparing the collected vector (first) exactly to the expected sequence
vec![InstallingConfigDepsStatus::Started, InstallingConfigDepsStatus::Done]
using assert_eq! so the test enforces order and no duplicates for the
installing-config-deps event stream.

In `@pacquet/crates/workspace-manifest-writer/src/lib.rs`:
- Around line 130-134: The current code always writes pnpm-workspace.yaml even
when edit::add_config_dependency returns false (no change); change the flow to
check the boolean result from edit::add_config_dependency and, if it indicates
no modification, return early (propagating no error) instead of calling
fs::write; keep the existing error mapping for the edit failure
(UpdateWorkspaceManifestError::Edit { path, source }) and only call
fs::write(manifest.into_text()) with its UpdateWorkspaceManifestError::Write
mapping when the edit returned true (i.e., the manifest was actually modified).

---

Nitpick comments:
In `@pacquet/crates/cli/tests/config_dependencies.rs`:
- Around line 37-44: Add diagnostic logging before each non-assert_eq! assertion
so CI failures show inspected values: e.g., print the computed workspace path
and the `installed` path and the `lockfile` contents before the assert! calls in
config_dependencies.rs (around the checks using variables `installed`,
`lockfile`, and `workspace`), using dbg! or eprintln! with clear labels; apply
the same pattern to the other non-assert_eq! blocks noted (the checks around the
ranges that reference the pnpm lockfile, `configDependencies`, and package
names) so tests emit the values being checked just prior to assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 595f9ebc-e6a3-4122-9e8e-e0530844a9c3

📥 Commits

Reviewing files that changed from the base of the PR and between d188316 and 7c7fa0b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (39)
  • Cargo.toml
  • pacquet/crates/cli/Cargo.toml
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/cli/src/cli_args/add.rs
  • pacquet/crates/cli/src/config_deps.rs
  • pacquet/crates/cli/src/lib.rs
  • pacquet/crates/cli/tests/config_dependencies.rs
  • pacquet/crates/config/Cargo.toml
  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/env-installer/Cargo.toml
  • pacquet/crates/env-installer/src/errors.rs
  • pacquet/crates/env-installer/src/install_config_deps.rs
  • pacquet/crates/env-installer/src/lib.rs
  • pacquet/crates/env-installer/src/options.rs
  • pacquet/crates/env-installer/src/parse_integrity.rs
  • pacquet/crates/env-installer/src/prune.rs
  • pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
  • pacquet/crates/env-installer/src/resolve_optional_subdeps.rs
  • pacquet/crates/env-installer/src/tests.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path/tests.rs
  • pacquet/crates/graph-hasher/src/lib.rs
  • pacquet/crates/hooks/src/finder.rs
  • pacquet/crates/hooks/src/lib.rs
  • pacquet/crates/hooks/src/node_runtime.rs
  • pacquet/crates/hooks/tests/node_hooks.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/lib.rs
  • pacquet/crates/lockfile/src/resolution.rs
  • pacquet/crates/lockfile/src/save_lockfile.rs
  • pacquet/crates/lockfile/src/yaml_documents.rs
  • pacquet/crates/package-manager/src/install.rs
  • pacquet/crates/package-manager/src/install_package_from_registry/tests.rs
  • pacquet/crates/reporter/src/lib.rs
  • pacquet/crates/workspace-manifest-writer/src/edit.rs
  • pacquet/crates/workspace-manifest-writer/src/lib.rs
  • pacquet/crates/workspace-manifest-writer/src/tests.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: ubuntu-latest / Node.js 24 / Test
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Code Coverage
🧰 Additional context used
📓 Path-based instructions (3)
pacquet/**/*.rs

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/*.rs: Log emissions are part of matching pnpm — when porting a function that fires pnpm:<channel> events through globalLogger, logger.debug(...), or streamParser.write(...), mirror the call site, payload, and ordering so @pnpm/cli.default-reporter parses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing the brand into a plain String or &str in Rust
If upstream TypeScript always validates before construction of a branded string, validate in the Rust wrapper too via TryFrom<String> and/or FromStr and do not provide an infallible public constructor
If upstream TypeScript never validates a branded string, just brand for type-safety in Rust by exposing an infallible From<String> constructor
If upstream TypeScript occasionally constructs a branded string without validation, expose from_str_unchecked in Rust as an escape hatch alongside the validating constructor
Match upstream serde behavior for branded strings crossing JSON, YAML, or INI boundaries by using #[serde(try_from = "String")] for deserialization and #[serde(into = "String")] for serialization
Derive simple conversions for branded strings using #[derive(derive_more::From)] and #[derive(derive_more::Into)] instead of handwriting impl blocks; use manual impl only when conversion needs custom logic
Model TypeScript string literal unions (like 'auto' | 'always' | 'never') as Rust enums instead of newtype wrappers, since the set of valid values is closed
Treat TypeScript string template literal types (like `${string}@${string}`) the same as branded string types in Rust, using a newtype wrapper with validation
Follow the code style guide in CODE_STYLE_GUIDE.md — imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching, pipe-trait, error handling, test layout, and cloning of Arc and Rc
Choose owned vs. borrowed parameters to minimize copies; widen to t...

Files:

  • pacquet/crates/cli/src/lib.rs
  • pacquet/crates/env-installer/src/prune.rs
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/package-manager/src/install_package_from_registry/tests.rs
  • pacquet/crates/lockfile/src/resolution.rs
  • pacquet/crates/package-manager/src/install.rs
  • pacquet/crates/graph-hasher/src/lib.rs
  • pacquet/crates/lockfile/src/lib.rs
  • pacquet/crates/hooks/src/node_runtime.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/env-installer/src/parse_integrity.rs
  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/save_lockfile.rs
  • pacquet/crates/workspace-manifest-writer/src/lib.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path/tests.rs
  • pacquet/crates/env-installer/src/errors.rs
  • pacquet/crates/reporter/src/lib.rs
  • pacquet/crates/hooks/src/finder.rs
  • pacquet/crates/hooks/tests/node_hooks.rs
  • pacquet/crates/lockfile/src/yaml_documents.rs
  • pacquet/crates/workspace-manifest-writer/src/tests.rs
  • pacquet/crates/env-installer/src/options.rs
  • pacquet/crates/cli/src/cli_args/add.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path.rs
  • pacquet/crates/env-installer/src/lib.rs
  • pacquet/crates/cli/tests/config_dependencies.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
  • pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
  • pacquet/crates/env-installer/src/install_config_deps.rs
  • pacquet/crates/workspace-manifest-writer/src/edit.rs
  • pacquet/crates/env-installer/src/resolve_optional_subdeps.rs
  • pacquet/crates/cli/src/config_deps.rs
  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/hooks/src/lib.rs
  • pacquet/crates/env-installer/src/tests.rs
pacquet/**/Cargo.toml

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/Cargo.toml: Check whether the workspace already depends on something suitable in [workspace.dependencies] in the root Cargo.toml before adding a new dependency
Keep dependencies at the right level — add a new dependency to the specific crate that needs it, not to the workspace root or shared crate unless multiple crates depend on it

Files:

  • pacquet/crates/config/Cargo.toml
  • pacquet/crates/env-installer/Cargo.toml
  • pacquet/crates/cli/Cargo.toml
pacquet/**/tests/**/*.rs

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/tests/**/*.rs: Prefer real fixtures over dependency-injection seams — use tempfile::TempDir, the mocked registry, or integration tests spawning the actual binary for happy paths and most error paths; use the DI seam only for filesystem error kinds, deterministic time, shared process-global state, or external-service happy paths
Follow test-logging guidance — log before non-assert_eq! assertions, dbg! complex structures, skip logging for simple scalar assert_eq!
Tests must not be tolerant of missing build/runtime environment by silently returning early when a tool isn't found — let the test panic when a required tool is absent
Prefer #[cfg_attr(target_os = "windows", ignore = "...")] (or matching #[cfg(unix)] gates) over runtime probe-and-skip helpers for platform-locked tools
Port relevant pnpm tests to Rust tests whenever they translate when porting behavior from pnpm
Use snapshot tests with insta and carefully review diffs when intentional changes alter snapshots; accept with cargo insta review only after careful review
Tests that need the mocked registry should start pnpr through pacquet-testing-utils; cargo test / cargo nextest run should not require a separate just registry-mock launch step

Files:

  • pacquet/crates/hooks/tests/node_hooks.rs
  • pacquet/crates/cli/tests/config_dependencies.rs
🧠 Learnings (5)
📚 Learning: 2026-05-20T19:40:55.051Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11774
File: pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs:0-0
Timestamp: 2026-05-20T19:40:55.051Z
Learning: In the pacquet Rust code, ensure the semver implementation uses the `node-semver` crate (not `nodejs-semver`). `node-semver`’s public API does not include a `satisfies_with_prerelease`-style method; prerelease-tolerant matching should be implemented inline by first calling `Range::satisfies`, and when it rejects a prerelease version, retry matching against a stripped `MAJOR.MINOR.PATCH` base of the prerelease version.

Applied to files:

  • pacquet/crates/cli/src/lib.rs
  • pacquet/crates/env-installer/src/prune.rs
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/package-manager/src/install_package_from_registry/tests.rs
  • pacquet/crates/lockfile/src/resolution.rs
  • pacquet/crates/package-manager/src/install.rs
  • pacquet/crates/graph-hasher/src/lib.rs
  • pacquet/crates/lockfile/src/lib.rs
  • pacquet/crates/hooks/src/node_runtime.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/env-installer/src/parse_integrity.rs
  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/save_lockfile.rs
  • pacquet/crates/workspace-manifest-writer/src/lib.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path/tests.rs
  • pacquet/crates/env-installer/src/errors.rs
  • pacquet/crates/reporter/src/lib.rs
  • pacquet/crates/hooks/src/finder.rs
  • pacquet/crates/hooks/tests/node_hooks.rs
  • pacquet/crates/lockfile/src/yaml_documents.rs
  • pacquet/crates/workspace-manifest-writer/src/tests.rs
  • pacquet/crates/env-installer/src/options.rs
  • pacquet/crates/cli/src/cli_args/add.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path.rs
  • pacquet/crates/env-installer/src/lib.rs
  • pacquet/crates/cli/tests/config_dependencies.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
  • pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
  • pacquet/crates/env-installer/src/install_config_deps.rs
  • pacquet/crates/workspace-manifest-writer/src/edit.rs
  • pacquet/crates/env-installer/src/resolve_optional_subdeps.rs
  • pacquet/crates/cli/src/config_deps.rs
  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/hooks/src/lib.rs
  • pacquet/crates/env-installer/src/tests.rs
📚 Learning: 2026-05-22T00:08:44.646Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11837
File: pacquet/crates/resolving-npm-resolver/src/pick_package.rs:33-51
Timestamp: 2026-05-22T00:08:44.646Z
Learning: In the pnpm/pnpm repo’s pacquet Rust crates, do not flag Unicode ellipsis characters (U+2026, `…`) in Rust doc comments (`///` / `/** */`) as a lint violation. The pacquet crate’s `dylint.toml` only enables `perfectionist::derive_ordering`, and the Dylint `unicode-ellipsis` rule is not enabled for this project—so `…` in doc comments is an intentional, repo-consistent style.

Applied to files:

  • pacquet/crates/cli/src/lib.rs
  • pacquet/crates/env-installer/src/prune.rs
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/package-manager/src/install_package_from_registry/tests.rs
  • pacquet/crates/lockfile/src/resolution.rs
  • pacquet/crates/package-manager/src/install.rs
  • pacquet/crates/graph-hasher/src/lib.rs
  • pacquet/crates/lockfile/src/lib.rs
  • pacquet/crates/hooks/src/node_runtime.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/env-installer/src/parse_integrity.rs
  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/save_lockfile.rs
  • pacquet/crates/workspace-manifest-writer/src/lib.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path/tests.rs
  • pacquet/crates/env-installer/src/errors.rs
  • pacquet/crates/reporter/src/lib.rs
  • pacquet/crates/hooks/src/finder.rs
  • pacquet/crates/hooks/tests/node_hooks.rs
  • pacquet/crates/lockfile/src/yaml_documents.rs
  • pacquet/crates/workspace-manifest-writer/src/tests.rs
  • pacquet/crates/env-installer/src/options.rs
  • pacquet/crates/cli/src/cli_args/add.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path.rs
  • pacquet/crates/env-installer/src/lib.rs
  • pacquet/crates/cli/tests/config_dependencies.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
  • pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
  • pacquet/crates/env-installer/src/install_config_deps.rs
  • pacquet/crates/workspace-manifest-writer/src/edit.rs
  • pacquet/crates/env-installer/src/resolve_optional_subdeps.rs
  • pacquet/crates/cli/src/config_deps.rs
  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/hooks/src/lib.rs
  • pacquet/crates/env-installer/src/tests.rs
📚 Learning: 2026-05-20T23:07:58.444Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11784
File: pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs:120-133
Timestamp: 2026-05-20T23:07:58.444Z
Learning: When reviewing code in this pacquet Rust port, follow the upstream pnpm compatibility rule: only match pnpm’s behavior exactly. Do not propose review changes that intentionally deviate from pnpm’s documented/observed behavior, even if pnpm appears buggy. If you identify a real bug in pnpm behavior, the review should prioritize fixing it upstream in pnpm first, and avoid implementing a pnpm-behavior workaround here unless the same fix has already landed upstream.

Applied to files:

  • pacquet/crates/cli/src/lib.rs
  • pacquet/crates/env-installer/src/prune.rs
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/package-manager/src/install_package_from_registry/tests.rs
  • pacquet/crates/lockfile/src/resolution.rs
  • pacquet/crates/package-manager/src/install.rs
  • pacquet/crates/graph-hasher/src/lib.rs
  • pacquet/crates/lockfile/src/lib.rs
  • pacquet/crates/hooks/src/node_runtime.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/env-installer/src/parse_integrity.rs
  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/save_lockfile.rs
  • pacquet/crates/workspace-manifest-writer/src/lib.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path/tests.rs
  • pacquet/crates/env-installer/src/errors.rs
  • pacquet/crates/reporter/src/lib.rs
  • pacquet/crates/hooks/src/finder.rs
  • pacquet/crates/hooks/tests/node_hooks.rs
  • pacquet/crates/lockfile/src/yaml_documents.rs
  • pacquet/crates/workspace-manifest-writer/src/tests.rs
  • pacquet/crates/env-installer/src/options.rs
  • pacquet/crates/cli/src/cli_args/add.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path.rs
  • pacquet/crates/env-installer/src/lib.rs
  • pacquet/crates/cli/tests/config_dependencies.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
  • pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
  • pacquet/crates/env-installer/src/install_config_deps.rs
  • pacquet/crates/workspace-manifest-writer/src/edit.rs
  • pacquet/crates/env-installer/src/resolve_optional_subdeps.rs
  • pacquet/crates/cli/src/config_deps.rs
  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/hooks/src/lib.rs
  • pacquet/crates/env-installer/src/tests.rs
📚 Learning: 2026-06-06T18:58:37.156Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 12243
File: pacquet/crates/package-manager/src/install_package_by_snapshot.rs:319-322
Timestamp: 2026-06-06T18:58:37.156Z
Learning: When reviewing Rust code, do not assume `matches!(expr, Pattern(_))` will move out of `expr` if `Pattern(_)` contains no by-value bindings. `matches!` desugars to a `match` that auto-borrows the scrutinee for discrimination, so even if `expr` is a non-`Copy` value behind a shared reference (e.g., `&T`), the macro should not move-out of the borrowed data purely due to `matches!`. Treat `matches!(&expr, Pattern(_))` as a readability/clarity improvement, not a correctness requirement. Only flag potential move-out-of-borrow risks when the pattern includes by-value bindings that would require moving the matched value.

Applied to files:

  • pacquet/crates/cli/src/lib.rs
  • pacquet/crates/env-installer/src/prune.rs
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/package-manager/src/install_package_from_registry/tests.rs
  • pacquet/crates/lockfile/src/resolution.rs
  • pacquet/crates/package-manager/src/install.rs
  • pacquet/crates/graph-hasher/src/lib.rs
  • pacquet/crates/lockfile/src/lib.rs
  • pacquet/crates/hooks/src/node_runtime.rs
  • pacquet/crates/config/src/workspace_yaml.rs
  • pacquet/crates/env-installer/src/parse_integrity.rs
  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/save_lockfile.rs
  • pacquet/crates/workspace-manifest-writer/src/lib.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path/tests.rs
  • pacquet/crates/env-installer/src/errors.rs
  • pacquet/crates/reporter/src/lib.rs
  • pacquet/crates/hooks/src/finder.rs
  • pacquet/crates/hooks/tests/node_hooks.rs
  • pacquet/crates/lockfile/src/yaml_documents.rs
  • pacquet/crates/workspace-manifest-writer/src/tests.rs
  • pacquet/crates/env-installer/src/options.rs
  • pacquet/crates/cli/src/cli_args/add.rs
  • pacquet/crates/graph-hasher/src/global_virtual_store_path.rs
  • pacquet/crates/env-installer/src/lib.rs
  • pacquet/crates/cli/tests/config_dependencies.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
  • pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
  • pacquet/crates/env-installer/src/install_config_deps.rs
  • pacquet/crates/workspace-manifest-writer/src/edit.rs
  • pacquet/crates/env-installer/src/resolve_optional_subdeps.rs
  • pacquet/crates/cli/src/config_deps.rs
  • pacquet/crates/config/src/lib.rs
  • pacquet/crates/hooks/src/lib.rs
  • pacquet/crates/env-installer/src/tests.rs
📚 Learning: 2026-05-20T23:23:40.606Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11788
File: pacquet/crates/lockfile/src/snapshot_dep_ref.rs:52-56
Timestamp: 2026-05-20T23:23:40.606Z
Learning: In the pacquet lockfile crate, the link target is intentionally represented as a raw `Link(String)` (not a `LinkTarget` newtype) for both `SnapshotDepRef::Link` and `ImporterDepVersion::Link`. pnpm upstream preserves `link:` targets verbatim with only a prefix check (e.g., `refToRelative`), so do not suggest introducing a `LinkTarget` newtype in a bug-fix/targeted PR. If type branding is ever desired, it should be done together for both variants in a separate dedicated refactor PR rather than as part of a small fix.

Applied to files:

  • pacquet/crates/lockfile/src/resolution.rs
  • pacquet/crates/lockfile/src/lib.rs
  • pacquet/crates/lockfile/src/env_lockfile/tests.rs
  • pacquet/crates/lockfile/src/save_lockfile.rs
  • pacquet/crates/lockfile/src/yaml_documents.rs
  • pacquet/crates/lockfile/src/env_lockfile.rs
🔇 Additional comments (28)
pacquet/crates/config/Cargo.toml (1)

14-14: LGTM!

pacquet/crates/config/src/workspace_yaml.rs (1)

75-75: LGTM!

pacquet/crates/hooks/src/lib.rs (1)

77-91: LGTM!

pacquet/crates/env-installer/src/lib.rs (1)

17-34: LGTM!

pacquet/crates/env-installer/src/resolve_optional_subdeps.rs (1)

20-154: LGTM!

pacquet/crates/env-installer/src/prune.rs (1)

16-48: LGTM!

pacquet/crates/hooks/src/node_runtime.rs (1)

149-159: LGTM!

pacquet/crates/lockfile/src/lib.rs (1)

3-3: LGTM!

Also applies to: 26-26

pacquet/crates/lockfile/src/env_lockfile.rs (1)

1-149: LGTM!

pacquet/crates/lockfile/src/yaml_documents.rs (1)

16-16: LGTM!

Also applies to: 21-21, 42-59

pacquet/crates/lockfile/src/save_lockfile.rs (1)

1-4: LGTM!

Also applies to: 57-90

pacquet/crates/lockfile/src/env_lockfile/tests.rs (1)

1-104: LGTM!

pacquet/crates/hooks/tests/node_hooks.rs (1)

465-545: LGTM!

pacquet/crates/workspace-manifest-writer/src/tests.rs (1)

204-243: LGTM!

pacquet/crates/lockfile/src/resolution.rs (1)

323-323: LGTM!

pacquet/crates/env-installer/Cargo.toml (1)

1-47: LGTM!

pacquet/crates/env-installer/src/parse_integrity.rs (1)

1-51: LGTM!

Cargo.toml (1)

28-28: LGTM!

pacquet/crates/cli/Cargo.toml (1)

18-43: LGTM!

pacquet/crates/cli/src/lib.rs (1)

2-2: LGTM!

pacquet/crates/env-installer/src/options.rs (1)

1-73: LGTM!

pacquet/crates/env-installer/src/install_config_deps.rs (1)

1-532: LGTM!

pacquet/crates/graph-hasher/src/global_virtual_store_path.rs (1)

23-27: LGTM!

Also applies to: 95-138

pacquet/crates/graph-hasher/src/lib.rs (1)

25-28: LGTM!

pacquet/crates/graph-hasher/src/global_virtual_store_path/tests.rs (1)

1-4: LGTM!

Also applies to: 6-6, 307-363

pacquet/crates/hooks/src/finder.rs (1)

1-4: LGTM!

Also applies to: 25-78

pacquet/crates/package-manager/src/install.rs (1)

440-449: LGTM!

pacquet/crates/package-manager/src/install_package_from_registry/tests.rs (1)

109-109: LGTM!

Comment thread pacquet/crates/cli/src/cli_args.rs
Comment thread pacquet/crates/cli/src/cli_args/add.rs Outdated
Comment thread pacquet/crates/cli/src/config_deps.rs Outdated
Comment thread pacquet/crates/env-installer/src/errors.rs Outdated
Comment thread pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs Outdated
Comment thread pacquet/crates/env-installer/src/tests.rs
Comment thread pacquet/crates/workspace-manifest-writer/src/lib.rs Outdated
Comment thread pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
Comment thread pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit f72f9eb

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 3f8994e

Comment thread pacquet/crates/lockfile/src/env_lockfile.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
pacquet/crates/cli/src/config_deps.rs (1)

262-273: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle deleted hook keys in config_delta (not only modified/present ones).

config_delta only iterates keys present in output, so hook deletions (e.g., removing overrides) are dropped and never reach WorkspaceSettings::apply_to. That leaves stale config values after updateConfig.

Proposed minimal fix
 fn config_delta(input: &Value, output: &Value) -> Value {
     let (Some(input_obj), Some(output_obj)) = (input.as_object(), output.as_object()) else {
         return output.clone();
     };
     let mut delta = serde_json::Map::new();
     for (key, value) in output_obj {
         if input_obj.get(key) != Some(value) {
             delta.insert(key.clone(), value.clone());
         }
     }
+    for key in input_obj.keys() {
+        if key != "catalogs" && !output_obj.contains_key(key) {
+            delta.insert(key.clone(), Value::Null);
+        }
+    }
     Value::Object(delta)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pacquet/crates/cli/src/config_deps.rs` around lines 262 - 273, The
config_delta function only considers keys present in output, so deletions in
input (e.g., removed hook keys like "overrides") are lost; modify config_delta
to iterate the union of keys from both input.as_object() and output.as_object(),
and for each key insert output's value when present or Value::Null when the key
exists in input but not in output (so WorkspaceSettings::apply_to can detect
deletions), keeping the existing behavior for unchanged values by comparing
input_obj.get(key) to output_obj.get(key).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@pacquet/crates/cli/src/config_deps.rs`:
- Around line 262-273: The config_delta function only considers keys present in
output, so deletions in input (e.g., removed hook keys like "overrides") are
lost; modify config_delta to iterate the union of keys from both
input.as_object() and output.as_object(), and for each key insert output's value
when present or Value::Null when the key exists in input but not in output (so
WorkspaceSettings::apply_to can detect deletions), keeping the existing behavior
for unchanged values by comparing input_obj.get(key) to output_obj.get(key).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 030f000d-bf2f-4ef0-af77-e48fa8a8ba1d

📥 Commits

Reviewing files that changed from the base of the PR and between e2b0afb and 3f8994e.

📒 Files selected for processing (11)
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/cli/src/cli_args/add.rs
  • pacquet/crates/cli/src/config_deps.rs
  • pacquet/crates/env-installer/src/errors.rs
  • pacquet/crates/env-installer/src/install_config_deps.rs
  • pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
  • pacquet/crates/env-installer/src/tests.rs
  • pacquet/crates/workspace-manifest-writer/src/edit.rs
  • pacquet/crates/workspace-manifest-writer/src/lib.rs
  • pacquet/crates/workspace-manifest-writer/src/model.rs
  • pacquet/crates/workspace-manifest-writer/src/tests.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • pacquet/crates/workspace-manifest-writer/src/lib.rs
  • pacquet/crates/cli/src/cli_args/add.rs
  • pacquet/crates/workspace-manifest-writer/src/tests.rs
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/env-installer/src/errors.rs
  • pacquet/crates/env-installer/src/resolve_and_install_config_deps.rs
  • pacquet/crates/workspace-manifest-writer/src/edit.rs
  • pacquet/crates/env-installer/src/install_config_deps.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Code Coverage
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Compile & Lint
🧰 Additional context used
📓 Path-based instructions (1)
pacquet/**/*.rs

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/*.rs: Log emissions are part of matching pnpm — when porting a function that fires pnpm:<channel> events through globalLogger, logger.debug(...), or streamParser.write(...), mirror the call site, payload, and ordering so @pnpm/cli.default-reporter parses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing the brand into a plain String or &str in Rust
If upstream TypeScript always validates before construction of a branded string, validate in the Rust wrapper too via TryFrom<String> and/or FromStr and do not provide an infallible public constructor
If upstream TypeScript never validates a branded string, just brand for type-safety in Rust by exposing an infallible From<String> constructor
If upstream TypeScript occasionally constructs a branded string without validation, expose from_str_unchecked in Rust as an escape hatch alongside the validating constructor
Match upstream serde behavior for branded strings crossing JSON, YAML, or INI boundaries by using #[serde(try_from = "String")] for deserialization and #[serde(into = "String")] for serialization
Derive simple conversions for branded strings using #[derive(derive_more::From)] and #[derive(derive_more::Into)] instead of handwriting impl blocks; use manual impl only when conversion needs custom logic
Model TypeScript string literal unions (like 'auto' | 'always' | 'never') as Rust enums instead of newtype wrappers, since the set of valid values is closed
Treat TypeScript string template literal types (like `${string}@${string}`) the same as branded string types in Rust, using a newtype wrapper with validation
Follow the code style guide in CODE_STYLE_GUIDE.md — imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching, pipe-trait, error handling, test layout, and cloning of Arc and Rc
Choose owned vs. borrowed parameters to minimize copies; widen to t...

Files:

  • pacquet/crates/workspace-manifest-writer/src/model.rs
  • pacquet/crates/env-installer/src/tests.rs
  • pacquet/crates/cli/src/config_deps.rs
🧠 Learnings (4)
📚 Learning: 2026-05-20T19:40:55.051Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11774
File: pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs:0-0
Timestamp: 2026-05-20T19:40:55.051Z
Learning: In the pacquet Rust code, ensure the semver implementation uses the `node-semver` crate (not `nodejs-semver`). `node-semver`’s public API does not include a `satisfies_with_prerelease`-style method; prerelease-tolerant matching should be implemented inline by first calling `Range::satisfies`, and when it rejects a prerelease version, retry matching against a stripped `MAJOR.MINOR.PATCH` base of the prerelease version.

Applied to files:

  • pacquet/crates/workspace-manifest-writer/src/model.rs
  • pacquet/crates/env-installer/src/tests.rs
  • pacquet/crates/cli/src/config_deps.rs
📚 Learning: 2026-05-22T00:08:44.646Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11837
File: pacquet/crates/resolving-npm-resolver/src/pick_package.rs:33-51
Timestamp: 2026-05-22T00:08:44.646Z
Learning: In the pnpm/pnpm repo’s pacquet Rust crates, do not flag Unicode ellipsis characters (U+2026, `…`) in Rust doc comments (`///` / `/** */`) as a lint violation. The pacquet crate’s `dylint.toml` only enables `perfectionist::derive_ordering`, and the Dylint `unicode-ellipsis` rule is not enabled for this project—so `…` in doc comments is an intentional, repo-consistent style.

Applied to files:

  • pacquet/crates/workspace-manifest-writer/src/model.rs
  • pacquet/crates/env-installer/src/tests.rs
  • pacquet/crates/cli/src/config_deps.rs
📚 Learning: 2026-05-20T23:07:58.444Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11784
File: pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs:120-133
Timestamp: 2026-05-20T23:07:58.444Z
Learning: When reviewing code in this pacquet Rust port, follow the upstream pnpm compatibility rule: only match pnpm’s behavior exactly. Do not propose review changes that intentionally deviate from pnpm’s documented/observed behavior, even if pnpm appears buggy. If you identify a real bug in pnpm behavior, the review should prioritize fixing it upstream in pnpm first, and avoid implementing a pnpm-behavior workaround here unless the same fix has already landed upstream.

Applied to files:

  • pacquet/crates/workspace-manifest-writer/src/model.rs
  • pacquet/crates/env-installer/src/tests.rs
  • pacquet/crates/cli/src/config_deps.rs
📚 Learning: 2026-06-06T18:58:37.156Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 12243
File: pacquet/crates/package-manager/src/install_package_by_snapshot.rs:319-322
Timestamp: 2026-06-06T18:58:37.156Z
Learning: When reviewing Rust code, do not assume `matches!(expr, Pattern(_))` will move out of `expr` if `Pattern(_)` contains no by-value bindings. `matches!` desugars to a `match` that auto-borrows the scrutinee for discrimination, so even if `expr` is a non-`Copy` value behind a shared reference (e.g., `&T`), the macro should not move-out of the borrowed data purely due to `matches!`. Treat `matches!(&expr, Pattern(_))` as a readability/clarity improvement, not a correctness requirement. Only flag potential move-out-of-borrow risks when the pattern includes by-value bindings that would require moving the matched value.

Applied to files:

  • pacquet/crates/workspace-manifest-writer/src/model.rs
  • pacquet/crates/env-installer/src/tests.rs
  • pacquet/crates/cli/src/config_deps.rs
🔇 Additional comments (2)
pacquet/crates/env-installer/src/tests.rs (1)

390-396: LGTM!

Also applies to: 466-507

pacquet/crates/workspace-manifest-writer/src/model.rs (1)

19-23: LGTM!

Also applies to: 32-44, 59-60, 68-84

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 6e284b5

Comment thread pacquet/crates/env-installer/src/install_config_deps.rs
@zkochan zkochan merged commit ea204b0 into main Jun 9, 2026
33 of 36 checks passed
@zkochan zkochan deleted the config-deps-pq branch June 9, 2026 17:47
zkochan added a commit that referenced this pull request Jun 9, 2026
…e-manager binary (#12292)

pnpm can be made to download and execute a native binary through two **repository-controlled** inputs, neither of which was authenticated before this change:

1. **pacquet install engine** — declaring `pacquet` (or `@pnpm/pacquet`) in `configDependencies` (in `pnpm-workspace.yaml`) opts in to pnpm's Rust install engine, and pnpm spawns the platform binary `@pacquet/<platform>-<arch>` during `pnpm install`.
2. **package-manager version switch** — the `packageManager` / `devEngines.packageManager` field makes pnpm download and run a specific pnpm version. This is **on by default** (`onFail` defaults to `download`) and also covers `pnpm self-update` and `pnpm with`.

In both cases the repository also controls the lockfile integrity and the registry the bytes are fetched from (via `.npmrc`), so matching the lockfile integrity proves nothing — it matches the hash the attacker wrote. A cloned, untrusted repository could therefore execute an arbitrary native binary just by running a normal pnpm command.

## Fix (corepack-style registry-signature verification)

pnpm now verifies the **npm registry signature** of the bytes it is about to spawn, **over the installed integrity**, against npm's public signing keys that **ship embedded in the pnpm CLI** (exactly as corepack does). If the bytes on disk were substituted or tampered with, npm's real signature does not validate over them.

- New reusable `verifyInstalledPackageSignatures()` in `@pnpm/deps.security.signatures` verifies `name@version:integrity` against `dist.signatures` using the embedded keys.
- Because the keys are **embedded** (not fetched), a registry the user did not vouch for cannot supply its own keypair to forge a signature. The signed packument is fetched from the **configured** registry, so an **npm mirror works transparently** — it proxies the same signed packument, with no configuration. There is intentionally **no runtime override or off-switch** for the keys.
- **pacquet** (`installing/commands`): verifies the `pacquet` shim and the host platform binary. It **fails the command** if the signature does not verify or cannot be checked (e.g. registry unreachable); the only graceful fallback to pnpm's own engine is when pacquet has no binary for the current platform.
- **pnpm engine** (`engine/pm/commands`): verifies `pnpm`, `@pnpm/exe`, and the host platform binary, **only on a store cache miss** (an actual download), so it adds no network round trip to every command. It **fails closed** — any verification failure, including an unreachable registry, refuses the version switch rather than running an unverified binary.

## Keeping the embedded keys fresh

The embedded keys live in a generated file. `deps/security/signatures/scripts/update-npm-signing-keys.mjs` keeps them in sync with npm's keys endpoint (`pnpm check:npm-signing-keys` / `--update`), and the **create-release-pr** workflow runs the check as a gate, so a key rotation cannot silently break verification — a stale key set blocks the release until refreshed.

## Pacquet parity

pacquet gained `configDependencies` support on `main` (#12285), but it has **no install-engine-spawn sink** — pacquet *is* the engine, and it does not select/spawn an alternate engine from `configDependencies` (its only config-dependency code-execution path is `updateConfig` plugin pnpmfiles, which it shares with pnpm and which this advisory does not cover). So CAND-PNPM-097 has no pacquet-side analog; no pacquet code change is needed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants