feat(executor): port makeEnv, extendPath, and shell selection for lifecycle scripts (#397)#418
Conversation
Ports `makeEnv` and the surrounding env block from `@pnpm/npm-lifecycle` at `d2d8e790` (`index.js:73-104`, `:354-414`) plus the pnpm-side wrapper's `extraEnv` additions at `pnpm/exec/lifecycle/src/runLifecycleHook.ts:119-124` (pinned to `b4f8f47ac2`). Lifecycle scripts now see: - `npm_lifecycle_event`, `npm_lifecycle_script` - `npm_node_execpath` / `NODE` (resolved against `PATH` when not supplied) - `npm_package_json`, `npm_execpath` - `npm_package_*` for `name`, `version`, and every key under `config`, `engines`, `bin` (filter mirrors `index.js:380-385`; multi-line values JSON-encode at `:406-408`) - `npm_config_node_gyp`, `npm_config_user_agent` when supplied - `INIT_CWD`, `PNPM_SCRIPT_SRC_DIR` - `TMPDIR` under `<wd>/node_modules/.tmp` when `unsafe_perm` is false (created on disk; `EEXIST` is tolerated) The spawn now strips inherited env via `env_clear()` so leftover `npm_*` / `pnpm_*` keys from a wrapping invocation cannot leak through, matching `makeEnv`'s `!i.match(/^npm_/)` filter at `index.js:358-362`. `RunPostinstallHooks` grows `node_execpath`, `npm_execpath`, `node_gyp_path`, `user_agent`, and `unsafe_perm` fields. The `BuildModules` caller passes `None`/`true` for all of them — the `unsafePerm` plumbing is item #14 in #397. Tests port the upstream `test('makeEnv')` invariants and add an end-to-end check that a spawned child sees the stamped variables and does NOT see leaked `npm_config_*` keys (adapted from `pnpm/exec/lifecycle/test/index.ts:65-77` using a file-dump model rather than an IPC fixture).
Ports `extendPath` from `@pnpm/npm-lifecycled2d8e790` at `lib/extendPath.js:5-27` (the walk) and `:29-61` (the tri-state `scriptsPrependNodePath` gating). The lifecycle hook's `PATH` now matches upstream's `wd.split(/[\\\\/]node_modules[\\\\/]/)` scheme: every ancestor `node_modules/.bin` from `wd` upward is prepended, deepest first, followed by the bundled `node-gyp-bin` slot, `extra_bin_paths`, and (when `scriptsPrependNodePath` is `Always`) `dirname(node)`. The inherited PATH stays last. The shape this unblocks for real installs is a dep at `<root>/node_modules/.pnpm/<slot>/node_modules/<pkg>` whose postinstall calls `node-gyp` — the previous PATH only prepended the dep's own (almost always empty) `.bin`, so the tool stayed unreachable. The new walk picks it up from the project root. `RunPostinstallHooks` grows `node_gyp_bin: Option<&Path>` and `scripts_prepend_node_path: ScriptsPrependNodePath`. `BuildModules` passes `None` / `Never` for both — bundling node-gyp and surfacing the `scriptsPrependNodePath` config are separate items in #397 (#15) and a follow-up. Tests port the upstream ordering test from `pnpm/npm-lifecycle/test/extendPath.test.js` and add coverage for the gaps upstream tests don't exercise: the no-ancestor case, two-level pnpm virtual-store layout, `extra_bin_paths` ordering, `original_path` placement, and all three `ScriptsPrependNodePath` variants.
Ports the shell-selection block from `@pnpm/npm-lifecycled2d8e790`
at `index.js:241-252` and the `.bat` / `.cmd` guard from
`pnpm/exec/lifecycle/src/runLifecycleHook.ts:63-71` (pinned to
`b4f8f47ac2`).
Priority of resolution:
1. `scriptShell` ending in `.cmd` or `.bat` on Windows is rejected
with `ERR_PNPM_INVALID_SCRIPT_SHELL_WINDOWS` (the same diagnostic
code upstream uses).
2. A non-batch `scriptShell` value wins on both platforms, invoked
with `-c`.
3. On Windows: `%ComSpec%` (or literal `cmd`) with `/d /s /c` and
the `windowsVerbatimArguments` intent flag set.
4. Otherwise: `sh -c` as before.
`select_shell` takes an explicit `is_windows: bool` so tests drive
both branches without `#[cfg(windows)]` gating; the production call
in `run_lifecycle_hook` passes `cfg!(windows)`.
`RunPostinstallHooks` grows `script_shell: Option<&Path>`;
`BuildModules` passes `None` (config plumbing for `script-shell` /
`shell-emulator` is a follow-up). The `shellEmulator` /
`@yarnpkg/shell` branch from `index.js:258-280` has no
straightforward Rust port and remains unimplemented — pacquet
silently ignores `shellEmulator: true` for now.
`windowsVerbatimArguments` (Rust equivalent:
`std::os::windows::process::CommandExt::raw_arg`) is signalled by
`SelectedShell.windows_verbatim_args` but not yet applied to the
spawned `Command`; the field is in place so a follow-up can wire
it without churn.
Tests port the upstream
`onlyOnWindows('pnpm shows error if script-shell is .cmd')` case
(`pnpm/exec/commands/test/index.ts:509-542`) and adapt the
custom-shell path from the same file (`:478-508`), plus add
coverage for the platform-default branches.
`just ready` rewrapped a few long argument lists and conditionals in the three new modules introduced by this branch (make_env, extend_path, shell) plus their test files. Pure formatting — no behavior change.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds modules to build child envs (make_env), compute PATH (extend_path), and select shells (shell); integrates them into the lifecycle runner to clear inherited env, compute PATH, optionally create TMPDIR, and spawn lifecycle scripts with manifest-derived npm package stamping. ChangesLifecycle Script Spawning with Environment and Shell Selection
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
CI surfaced two classes of issue on the new lifecycle modules: - `dylint` (perfectionist macro-trailing-comma) flagged five `assert*!`/`format!` sites where `cargo fmt`'s rewraps left the invocations in the disallowed shape: trailing comma on a single-line macro, missing trailing comma on a multi-line one. - `rustdoc -D warnings` rejected a Windows-only intra-doc link to `std::os::windows::process::CommandExt::raw_arg` (the path doesn't resolve on non-Windows targets where docs build) and a redundant explicit `[`build_env`](crate::build_env)` target. Pure lint fixes — no behavior or test-coverage change. Tests still pass and `RUSTDOCFLAGS=-D warnings cargo doc` is clean for `pacquet-executor`.
There was a problem hiding this comment.
Pull request overview
Ports additional pnpm lifecycle-script behavior into pacquet_executor so dependency build hooks run with upstream-compatible env/PATH/shell semantics.
Changes:
- Add
make_envenv builder to stampnpm_lifecycle_*,npm_package_*, TMPDIR gating, and strip inheritednpm_*/pnpm_*leakage. - Add
extend_pathPATH construction to walknode_modules/.binancestors and support tri-statescriptsPrependNodePath. - Add
shellselection to usecmd /d /s /con Windows (and reject.bat/.cmdcustom shells), otherwisesh -c/scriptShell.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/package-manager/src/build_modules.rs | Wires new lifecycle options into RunPostinstallHooks with safe defaults. |
| crates/executor/src/lib.rs | Exposes new executor modules (extend_path, make_env, shell) via re-exports. |
| crates/executor/src/lifecycle.rs | Uses build_env, extend_path, and select_shell; clears inherited env before spawning. |
| crates/executor/src/lifecycle/tests.rs | Extends lifecycle tests and adds an end-to-end env-dump assertion. |
| crates/executor/src/make_env.rs | Implements pnpm-compatible env stamping/filtering for lifecycle spawns. |
| crates/executor/src/make_env/tests.rs | Adds unit tests for env stamping/filtering/TMPDIR behavior. |
| crates/executor/src/extend_path.rs | Implements pnpm-compatible PATH ancestor walk and tri-state node-dir prepend. |
| crates/executor/src/extend_path/tests.rs | Adds ordering and virtual-store layout tests for PATH construction. |
| crates/executor/src/shell.rs | Implements platform/default/custom shell selection + Windows batch-shell guard. |
| crates/executor/src/shell/tests.rs | Adds tests for POSIX/Windows defaults and batch-shell rejection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@crates/executor/src/lifecycle.rs`:
- Around line 242-250: The code uses built.env.get("PATH") which fails on
Windows because env keys preserve system casing (e.g., "Path"); change the
lookup to perform a case-insensitive search for the PATH key before calling
extend_path: replace built.env.get("PATH").map(...) with logic that iterates
built.env (or uses built.env.keys()) to find a key where
key.eq_ignore_ascii_case("PATH") and then map that value to OsString (preserving
existing mapping code), so original_path gets the inherited PATH on Windows;
keep all other arguments to extend_path (opts.pkg_root, opts.node_gyp_bin,
opts.extra_bin_paths, opts.scripts_prepend_node_path, opts.node_execpath)
unchanged.
In `@crates/executor/src/lifecycle/tests.rs`:
- Around line 256-258: Remove the unnecessary unsafe blocks and accompanying
SAFETY comment around calls to std::env::set_var and std::env::remove_var in the
tests (the blocks wrapping env::set_var("npm_config_should_be_stripped", "leak")
and env::remove_var("npm_config_should_be_stripped")). Since these functions
have been safe since Rust 1.62, delete the unsafe { ... } wrappers and the
outdated SAFETY comment at both locations (around the set_var and remove_var
calls) so the calls are plain safe calls to std::env::set_var and
std::env::remove_var.
🪄 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: 4f711f2c-294c-48f3-b369-2f56a59f6458
📒 Files selected for processing (10)
crates/executor/src/extend_path.rscrates/executor/src/extend_path/tests.rscrates/executor/src/lib.rscrates/executor/src/lifecycle.rscrates/executor/src/lifecycle/tests.rscrates/executor/src/make_env.rscrates/executor/src/make_env/tests.rscrates/executor/src/shell.rscrates/executor/src/shell/tests.rscrates/package-manager/src/build_modules.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). (6)
- GitHub Check: Agent
- GitHub Check: Lint and Test (macos-latest)
- GitHub Check: Lint and Test (windows-latest)
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Code Coverage
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs
📄 CodeRabbit inference engine (AGENTS.md)
**/*.rs: Log emissions are part of 'match pnpm'—when porting a function that firespnpm:<channel>events throughglobalLogger,logger.debug(...), orstreamParser.write(...), mirror the call site, payload, and ordering so@pnpm/cli.default-reporterparses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing into plainStringor&strto preserve the distinction between validating and non-validating brands
If upstream always validates before construction of a branded string type, implement validation viaTryFrom<String>and/orFromStrwithout providing an infallible public constructor
If upstream never validates a branded string type, expose an infallibleFrom<String>constructor to provide type-safety without runtime validation
When upstream occasionally constructs a branded string type without validation via bareasassertion, expose afrom_str_uncheckedconstructor so callers can opt into the same unchecked path explicitly
Match upstream serde behavior for branded types—use#[serde(try_from = "String")]for deserialization to go through the validator and#[serde(into = "String")]for serialization
Use#[derive(derive_more::From)]and#[derive(derive_more::Into)]for mechanical conversion impls on branded types rather than handwritingimplblocks
Model string-literal unions (like'auto' | 'always' | 'never') as Rustenums instead of newtype wrappers
Template literal types (like${string}@${string}) should be treated as branded string types with the same validation discipline applied
Follow the code style guide inCODE_STYLE_GUIDE.mdcovering code-level conventions not enforced by tooling: imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching,pipe-trait, error handling, test layout, and cloning ofArcandRc
Warnings are errors incargo clippy --locked -- --deny warnings, and should not be silenced with `#[allow(....
Files:
crates/executor/src/shell.rscrates/executor/src/lib.rscrates/executor/src/shell/tests.rscrates/executor/src/extend_path.rscrates/executor/src/make_env.rscrates/executor/src/make_env/tests.rscrates/package-manager/src/build_modules.rscrates/executor/src/extend_path/tests.rscrates/executor/src/lifecycle/tests.rscrates/executor/src/lifecycle.rs
🧠 Learnings (2)
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.
Applied to files:
crates/executor/src/shell.rscrates/executor/src/lib.rscrates/executor/src/shell/tests.rscrates/executor/src/extend_path.rscrates/executor/src/make_env.rscrates/executor/src/make_env/tests.rscrates/package-manager/src/build_modules.rscrates/executor/src/extend_path/tests.rscrates/executor/src/lifecycle/tests.rscrates/executor/src/lifecycle.rs
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., `eprintln!`/`eprintln!(...)`) so that useful diagnostic values are printed when a test fails, unless the assertion is `assert_eq!` (where the differing values are already included). Concretely, if you use assertions like `assert!`, `assert_ne!`, etc., ensure the test logs the relevant actual/expected values (or context) before/around the assertion so failures can be diagnosed without rerunning.
Applied to files:
crates/executor/src/shell/tests.rscrates/executor/src/make_env/tests.rscrates/executor/src/extend_path/tests.rscrates/executor/src/lifecycle/tests.rs
🔇 Additional comments (4)
crates/executor/src/make_env.rs (1)
1-209: Well-structured port ofmakeEnvwith clear documentation.The environment builder correctly implements the upstream logic:
- Parent env filtering strips
npm_*/pnpm_*stamping keys- Recursive
stamp_packagehandles the top-level keep-list (name/version/config/engines/bin) and nested descent correctly- Multi-line string escaping via JSON encoding matches the JS
JSON.stringifybehaviornpm_lifecycle_scriptis correctly stamped last to preventextra_envfrom clobbering itThe
find_node_in_path()fallback usingenv::var_os("PATH")rather than the passedparent_envis acceptable since callers can provide an explicitnode_execpathwhen test isolation is needed.crates/executor/src/lifecycle.rs (3)
59-67: LGTM: NewScriptShellerror variant.Clean addition with proper
miettediagnostic code and#[error(source)]for the wrappedScriptShellError.
210-237: LGTM: Environment construction and TMPDIR handling.The
EnvOptionsconstruction andbuild_envcall are well-wired. The TMPDIR creation withAlreadyExistshandling mirrors the upstreamindex.js:97-102defensive check.
264-279: LGTM: Shell selection and command setup.The
.env_clear()followed by explicit.envs(&built.env)correctly isolates the child from inheritednpm_*keys. Thelet _ = shell.windows_verbatim_args;acknowledges the deferred TODO cleanly.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #418 +/- ##
==========================================
+ Coverage 84.55% 84.72% +0.17%
==========================================
Files 75 78 +3
Lines 5159 5415 +256
==========================================
+ Hits 4362 4588 +226
- Misses 797 827 +30 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Micro-Benchmark ResultsLinux |
The Windows CI runner caught two test-only Windows regressions
introduced by the shell-selection change earlier in this branch:
- `extend_path::tests::pnpm_virtual_store_layout_yields_three_bins_deepest_first`
hard-coded `/proj/...` expected values. `path::absolute("/proj")`
on Windows resolves against the current drive (`C:\proj`), making
those expectations racy with the runner. Split into a Unix-gated
test with literal expectations and a new platform-neutral test
that asserts the structural invariants (count + deepest-first
ordering) without anchoring to any absolute root.
- `lifecycle::tests::lifecycle_emits_script_stdio_and_exit_in_order`
and the new `child_sees_stamped_npm_package_and_no_leaked_npm_config`
both use POSIX-only script syntax (`;`, `1>&2`, `printf`, `$VAR`).
Before this branch the spawn used a hard-coded `sh -c` so Git
Bash on the Windows runner satisfied them; once the shell
selector started picking `cmd /d /s /c` on Windows (per item #4
/ parity with pnpm) those tests can no longer run there. Gate
them on `cfg(unix)`. The cmd-path shell selection itself is
covered by `crate::shell` unit tests; env stamping by
`crate::make_env` units.
No production-code change.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
crates/executor/src/lifecycle/tests.rs (1)
264-317:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftUnsafe env mutation lacks enforced isolation and panic-safe cleanup.
At Line 264-271 and Line 315-317, the test mutates process-global env, but the safety comment itself acknowledges possible concurrent env reads. That does not satisfy the unsafe contract. Also, if
run_postinstall_hookspanics, cleanup is skipped and the var leaks into later tests.#!/bin/bash set -euo pipefail # 1) Check whether test execution is explicitly serialized anywhere. rg -n --hidden -S 'nextest|test-threads|RUST_TEST_THREADS|serial_test|harness\s*=\s*false' # 2) Check whether other tests may read/write process env concurrently. rg -n --type rust 'std::env::(var|vars|set_var|remove_var)\('Expected verification outcome: if execution is not forced to single-thread/single-process for this test context, and other env reads/writes exist, this unsafe usage is unsound. Consider moving this case to an isolated subprocess-based test and adding a drop guard so cleanup always runs.
As per coding guidelines: "Do not introduce
unsafewithout a clear justification and review".🤖 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 `@crates/executor/src/lifecycle/tests.rs` around lines 264 - 317, The test unsafely mutates process-global env ("npm_config_should_be_stripped") without serialization and with panic-unsafe cleanup; replace the direct unsafe std::env::set_var/remove_var usage in this test by running the scenario in an isolated subprocess (spawn Command that sets the env and executes the postinstall logic or a small test helper) or, if you must keep it in-process, wrap the set/remove in a panic-safe guard (e.g., use std::panic::catch_unwind or a Drop-based scope guard) so remove_var always runs; locate the mutation around npm_config_should_be_stripped and the call to run_postinstall_hooks::<SilentReporter> and move the env mutation into the child or protect it with the guard to ensure deterministic isolation and no leak to other tests.
🤖 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 `@crates/executor/src/lifecycle/tests.rs`:
- Around line 312-314: The test currently does a bare assert!(ran) after calling
run_postinstall_hooks::<SilentReporter>(opts); replace that with an assertion
that includes context so failures show the actual value: use assert!(ran,
"run_postinstall_hooks::<SilentReporter>(...) returned false: ran = {:?}", ran)
or equivalent; reference the call/run_postinstall_hooks::<SilentReporter> and
the ran variable so the failure message clearly shows the returned value in CI
logs.
---
Duplicate comments:
In `@crates/executor/src/lifecycle/tests.rs`:
- Around line 264-317: The test unsafely mutates process-global env
("npm_config_should_be_stripped") without serialization and with panic-unsafe
cleanup; replace the direct unsafe std::env::set_var/remove_var usage in this
test by running the scenario in an isolated subprocess (spawn Command that sets
the env and executes the postinstall logic or a small test helper) or, if you
must keep it in-process, wrap the set/remove in a panic-safe guard (e.g., use
std::panic::catch_unwind or a Drop-based scope guard) so remove_var always runs;
locate the mutation around npm_config_should_be_stripped and the call to
run_postinstall_hooks::<SilentReporter> and move the env mutation into the child
or protect it with the guard to ensure deterministic isolation and no leak to
other tests.
🪄 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: d4ecfe87-e65c-40bd-8191-8af720b703f4
📒 Files selected for processing (2)
crates/executor/src/extend_path/tests.rscrates/executor/src/lifecycle/tests.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- crates/executor/src/extend_path/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). (4)
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Lint and Test (windows-latest)
- GitHub Check: Code Coverage
- GitHub Check: Run benchmark on ubuntu-latest
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs
📄 CodeRabbit inference engine (AGENTS.md)
**/*.rs: Log emissions are part of 'match pnpm'—when porting a function that firespnpm:<channel>events throughglobalLogger,logger.debug(...), orstreamParser.write(...), mirror the call site, payload, and ordering so@pnpm/cli.default-reporterparses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing into plainStringor&strto preserve the distinction between validating and non-validating brands
If upstream always validates before construction of a branded string type, implement validation viaTryFrom<String>and/orFromStrwithout providing an infallible public constructor
If upstream never validates a branded string type, expose an infallibleFrom<String>constructor to provide type-safety without runtime validation
When upstream occasionally constructs a branded string type without validation via bareasassertion, expose afrom_str_uncheckedconstructor so callers can opt into the same unchecked path explicitly
Match upstream serde behavior for branded types—use#[serde(try_from = "String")]for deserialization to go through the validator and#[serde(into = "String")]for serialization
Use#[derive(derive_more::From)]and#[derive(derive_more::Into)]for mechanical conversion impls on branded types rather than handwritingimplblocks
Model string-literal unions (like'auto' | 'always' | 'never') as Rustenums instead of newtype wrappers
Template literal types (like${string}@${string}) should be treated as branded string types with the same validation discipline applied
Follow the code style guide inCODE_STYLE_GUIDE.mdcovering code-level conventions not enforced by tooling: imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching,pipe-trait, error handling, test layout, and cloning ofArcandRc
Warnings are errors incargo clippy --locked -- --deny warnings, and should not be silenced with `#[allow(....
Files:
crates/executor/src/lifecycle/tests.rs
🧠 Learnings (2)
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., `eprintln!`/`eprintln!(...)`) so that useful diagnostic values are printed when a test fails, unless the assertion is `assert_eq!` (where the differing values are already included). Concretely, if you use assertions like `assert!`, `assert_ne!`, etc., ensure the test logs the relevant actual/expected values (or context) before/around the assertion so failures can be diagnosed without rerunning.
Applied to files:
crates/executor/src/lifecycle/tests.rs
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.
Applied to files:
crates/executor/src/lifecycle/tests.rs
Two more Windows-only test regressions slipped through the previous pass: - `make_env::tests::make_env_stamps_lifecycle_specific_keys` and `make_env::tests::make_env_tmpdir_gating_mirrors_unsafe_perm` hard-coded forward-slash expected values for `npm_package_json`, `INIT_CWD`, `PNPM_SCRIPT_SRC_DIR`, and `TMPDIR`. On Windows `Path::join` emits `\` separators, so the assertions break. Build expected values through the same `Path::join` path so separator handling stays `std`'s problem, not the test's. - `extend_path::tests::virtual_store_walk_orders_deepest_first`'s multi-line `assert!` collapsed to single-line-plus-trailing-comma under `cargo fmt`, which the dylint `perfectionist` rule rejects. Drop the trailing comma so fmt and dylint agree.
Five production-code fixes and three test-quality fixes from Copilot + coderabbit on PR #418: - `is_stamping_key` narrowed from `npm_* | pnpm_*` to just `npm_*`, matching upstream's `!i.match(/^npm_/)` at `npm-lifecycle/index.js:359`. Dropping every `pnpm_*` key would have stripped `PNPM_HOME` and feature flags upstream preserves. - `find_node_in_path` no longer reads the process-global `PATH` via `env::var_os`. It now takes the parent env's PATH as a parameter, so `build_env` is fully deterministic given its inputs (matching the docstring contract). - Look up `PATH` from `parent_env` case-insensitively via a new `path_value` helper. Windows preserves the system casing (typically `Path`) on env keys, so the previous `built.env.get("PATH")` call returned `None` there and `extend_path` lost the inherited system PATH. - `run_postinstall_hooks` captures the parent env once and threads it through to each lifecycle hook, instead of calling `env::vars().collect()` from inside `run_lifecycle_hook` on every hook spawn. The three hooks for one package now observe a consistent snapshot. - TMPDIR creation drops the `AlreadyExists`-swallow special case. `fs::create_dir_all` returns `Ok(())` when the path already exists *as a directory*; `AlreadyExists` from it specifically signals a non-directory file at that path, which is a real error that should surface as a spawn failure. Test fixes: - `child_sees_stamped_npm_package_and_no_leaked_npm_config` now installs a `Drop`-based env guard around the `set_var` call, so an assertion failure cleans up the seed instead of leaking it into sibling tests. Stray `dbg!(dump)` removed; bare `assert!(ran)` picks up a diagnostic message. - `make_env_stamps_top_level_keys_and_strips_npm_config_leakage` swapped the `pnpm_config_user_agent`-must-be-stripped assertion for a `PNPM_HOME`-must-pass-through assertion. Mirrors the narrowed filter and the upstream behavior.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@crates/executor/src/make_env.rs`:
- Around line 63-124: is_stamping_key and filter_parent_env perform
case-sensitive env-key checks which on Windows lets duplicated keys (e.g.,
NPM_CONFIG_FOO vs npm_config_foo, Node vs NODE) pass through; update both
functions to normalize keys using the same case-insensitive normalization used
by path_value (e.g., map keys to a canonical lowercase or use path_value's
normalization helper) before calling starts_with("npm_") or matches! so
comparisons are Windows-safe, and ensure filter_parent_env de-duplicates using
that normalized form so inserted keys from stamp_package,
npm_node_execpath/NODE, npm_execpath, INIT_CWD, TMPDIR, etc. won't create
conflicting duplicates on Windows.
🪄 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: 908a1771-63c5-4ae2-bcd2-3809e5a656c1
📒 Files selected for processing (4)
crates/executor/src/lifecycle.rscrates/executor/src/lifecycle/tests.rscrates/executor/src/make_env.rscrates/executor/src/make_env/tests.rs
🚧 Files skipped from review as they are similar to previous changes (3)
- crates/executor/src/make_env/tests.rs
- crates/executor/src/lifecycle.rs
- crates/executor/src/lifecycle/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). (1)
- GitHub Check: Run benchmark on ubuntu-latest
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs
📄 CodeRabbit inference engine (AGENTS.md)
**/*.rs: Log emissions are part of 'match pnpm'—when porting a function that firespnpm:<channel>events throughglobalLogger,logger.debug(...), orstreamParser.write(...), mirror the call site, payload, and ordering so@pnpm/cli.default-reporterparses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing into plainStringor&strto preserve the distinction between validating and non-validating brands
If upstream always validates before construction of a branded string type, implement validation viaTryFrom<String>and/orFromStrwithout providing an infallible public constructor
If upstream never validates a branded string type, expose an infallibleFrom<String>constructor to provide type-safety without runtime validation
When upstream occasionally constructs a branded string type without validation via bareasassertion, expose afrom_str_uncheckedconstructor so callers can opt into the same unchecked path explicitly
Match upstream serde behavior for branded types—use#[serde(try_from = "String")]for deserialization to go through the validator and#[serde(into = "String")]for serialization
Use#[derive(derive_more::From)]and#[derive(derive_more::Into)]for mechanical conversion impls on branded types rather than handwritingimplblocks
Model string-literal unions (like'auto' | 'always' | 'never') as Rustenums instead of newtype wrappers
Template literal types (like${string}@${string}) should be treated as branded string types with the same validation discipline applied
Follow the code style guide inCODE_STYLE_GUIDE.mdcovering code-level conventions not enforced by tooling: imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching,pipe-trait, error handling, test layout, and cloning ofArcandRc
Warnings are errors incargo clippy --locked -- --deny warnings, and should not be silenced with `#[allow(....
Files:
crates/executor/src/make_env.rs
Rust's `Command::env` treats env keys case-insensitively on Windows. Combined with our case-sensitive filter, a parent env containing e.g. `NPM_CONFIG_FOO` (uppercase) would pass through unfiltered; when we then inserted `npm_lifecycle_event` and friends, the spawn- side env block would carry both casings of the same Windows-logical variable, with the surviving value picked unpredictably from HashMap iteration order. Make `is_stamping_key` take an explicit `is_windows: bool` and use `eq_ignore_ascii_case` for the prefix and exact-match arms on Windows. Production callers pass `cfg!(windows)`; tests cover both branches. Coverage: - POSIX path: matches `/^npm_/` literally; `Node`, `NPM_CONFIG_*`, etc. all survive (they're distinct variables on POSIX). - Windows path: strips any-case `npm_` prefix and `NODE` / `TMPDIR` / `INIT_CWD` / `PNPM_SCRIPT_SRC_DIR` exact-matches in any case. - `PNPM_HOME` and other `pnpm_*` keys still pass through on both platforms — they're not in upstream's strip set.
The `// Item #14 will replace this …` / `// Item #15 in #397 …` comments referenced specific items in the audit issue this PR partially closes. Per `CLAUDE.md`: Don't reference the current task, fix, or callers ("used by X", "added for the Y flow", "handles the case from issue #123"), since those belong in the PR description and rot as the codebase evolves. Replaced the substantive WHY (placeholder default until config is plumbed) without naming the issue, and dropped the comments that were just restating the field name. Same applies to the `RunPostinstallHooks.unsafe_perm` doc comment.
Three issues raised by Copilot on the case-insensitive filter
commit:
- `is_stamping_key` previously did `key[..4].eq_ignore_ascii_case(...)`
on the Windows branch. That can panic when `key` is non-ASCII and
byte 4 lands inside a multi-byte codepoint (e.g. `"x\u{10000}"`
is 5 bytes with a 4-byte codepoint starting at byte 1). Switched
to a byte-level prefix check via `key.as_bytes().get(..4)`, which
short-circuits on non-ASCII keys without panicking. Added a
regression test covering the panic-y inputs.
- `run_lifecycle_hook` previously called `.envs(&built.env).env("PATH", ...)`,
relying on `Command::env`'s case-insensitive dedup on Windows to
beat the inherited `Path` key. The dedup behavior is real (and
documented), but relying on it leaves an unspecified-winner edge
case visible to the spawned process. Strip any PATH-like key
from the env map case-insensitively before inserting our
computed `PATH`, so the spawn sees exactly one PATH slot.
- `ancestor_node_modules_bins` previously fell back to an empty
`PathBuf` when `head` was empty (i.e. `wd` starts with
`node_modules/`). Upstream's `path.resolve("")` returns the
process cwd; switch to `env::current_dir()` for that case to
match. Added a code comment so the cwd-dependence isn't
surprising — pacquet's production call sites always pass an
absolute `pkg_root`, so the dependency is a non-issue in
practice, but matching upstream removes the divergence.
| #[display( | ||
| "Cannot spawn .bat or .cmd as a script shell. \ | ||
| The pnpm-workspace.yaml scriptShell option was configured to a .bat or .cmd file. \ | ||
| These cannot be used as a script shell reliably. \ | ||
| Please unset the scriptShell option, or configure it to a .exe instead. \ | ||
| (scriptShell={path})" | ||
| )] |
There was a problem hiding this comment.
Pushing back on this one. The text is a verbatim port of pnpm v11's hint at exec/lifecycle/src/runLifecycleHook.ts:64-69 (pinned to b4f8f47ac2):
The pnpm-workspace.yaml scriptShell option was configured to a .bat or .cmd file. These cannot be used as a script shell reliably.
Please unset the scriptShell option, or configure it to a .exe instead.
Per this repo's CLAUDE.md:
Do not invent behavior that pnpm does not have. Do not 'fix' pnpm quirks unless the same fix has landed upstream.
User-facing diagnostic strings are part of the contract (ERR_PNPM_INVALID_SCRIPT_SHELL_WINDOWS is in pnpm's public error catalog), so I'd rather mirror upstream and let the source-agnostic phrasing be a pnpm upstream PR that we then port. Happy to revisit if @zkochan wants to diverge for pacquet specifically.
Written by an agent (Claude Code, claude-opus-4-7).
Copilot flagged that `env::join_paths` errors when any path component contains the platform separator, and my fallback discarded every computed entry — `.bin` walk, node-gyp shim, extra paths — leaving lifecycle scripts with only the inherited PATH (or empty). Upstream's `pathArr.join(process.platform === 'win32' ? ';' : ':')` at `lib/extendPath.js:26` does an unvalidated string join; if a component contains the separator, the spawned shell sees an embedded extra entry, which is the same outcome upstream produces. Replaced `env::join_paths` with a tiny `join_paths_lossy` helper that does the literal string join, matching upstream exactly. Added a POSIX-gated regression test (`separator_in_path_component_does_not_drop_other_entries`) that exercises an `extra_bin_paths` component containing `:` and asserts both the wd `.bin` and the weird extra path survive.
Integrated-Benchmark Report (Linux)Scenario: Frozen Lockfile
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.53834279366,
"stddev": 0.07609073078478107,
"median": 2.5049310393599997,
"user": 2.51874312,
"system": 3.493900959999999,
"min": 2.44785918036,
"max": 2.65009997636,
"times": [
2.61830636036,
2.4907442353600002,
2.49625183136,
2.65009997636,
2.46426930736,
2.64221253536,
2.51361024736,
2.57461473936,
2.44785918036,
2.48545952336
]
},
{
"command": "pacquet@main",
"mean": 2.51423552276,
"stddev": 0.07753500308971267,
"median": 2.49482211086,
"user": 2.54900032,
"system": 3.4978371599999996,
"min": 2.42030591736,
"max": 2.68710937036,
"times": [
2.49481209636,
2.59565837336,
2.49067148036,
2.42030591736,
2.49278487736,
2.68710937036,
2.43086092536,
2.49483212536,
2.51424402636,
2.52107603536
]
},
{
"command": "pnpm",
"mean": 6.27363785366,
"stddev": 0.10829208953007195,
"median": 6.2943365903599995,
"user": 9.07003992,
"system": 4.59820476,
"min": 6.0884925033599995,
"max": 6.41395913436,
"times": [
6.17161222536,
6.38708013436,
6.41395913436,
6.3701903913599995,
6.0884925033599995,
6.151726733359999,
6.3142717383599996,
6.274401442359999,
6.316308181359999,
6.24833605236
]
}
]
}Scenario: Frozen Lockfile (Hot Cache)
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.6693595670600001,
"stddev": 0.052606689818215986,
"median": 0.6569874342600001,
"user": 0.35250017999999994,
"system": 1.4662264799999998,
"min": 0.63507351176,
"max": 0.8133982727600001,
"times": [
0.8133982727600001,
0.6660129837600001,
0.6374919117600001,
0.6632978767600001,
0.6720206817600001,
0.6724918647600001,
0.65067699176,
0.64381089976,
0.6393206757600001,
0.63507351176
]
},
{
"command": "pacquet@main",
"mean": 0.6751510434599999,
"stddev": 0.028650265776925546,
"median": 0.6666921652600001,
"user": 0.3591970799999999,
"system": 1.4955684799999998,
"min": 0.65255358476,
"max": 0.7480579487600001,
"times": [
0.7480579487600001,
0.67562661276,
0.65255358476,
0.66278960576,
0.6637614997600001,
0.6952363547600001,
0.65401501476,
0.6696228307600001,
0.65520736176,
0.6746396207600001
]
},
{
"command": "pnpm",
"mean": 2.6433685097599997,
"stddev": 0.10692195525631905,
"median": 2.60408245226,
"user": 3.17180188,
"system": 2.2786787800000003,
"min": 2.5442516727599997,
"max": 2.90346864276,
"times": [
2.60493870076,
2.5442516727599997,
2.70287111176,
2.7098375037599998,
2.59759767276,
2.5569915527599996,
2.57438244376,
2.63611959276,
2.90346864276,
2.60322620376
]
}
]
} |
Dylint blocker + seven Copilot threads: - `crates/reporter/src/tests.rs:636` — `assert!` collapsed to a single-line trailing-comma form by `cargo fmt`, which dylint's `perfectionist::macro-trailing-comma` rejects. Remove the trailing comma. (Same conflict we hit on #418.) - `pnpm:lifecycle` events now carry the dep's `optional` flag. Previously `LifecycleMessage::Script` / `Exit` were hard-coded to `optional: false`, so an optional dep's lifecycle records diverged from upstream's `lifecycleLogger.debug({ optional, … })` payloads at `pnpm/exec/lifecycle/src/runLifecycleHook.ts:102` and `:165`. Added `RunPostinstallHooks.optional`, threaded through to both emit sites, and added a `cfg(unix)` test asserting the flag survives the spawn-and-capture round-trip. `BuildModules` reads the value from `SnapshotEntry.optional` (already in the lockfile, now in the wire too). - Tightened the `SkippedOptionalDependencyLog` doc comment to explain that `ResolutionFailure` has a distinct `package` shape upstream (`bareSpecifier`, no `id`) and will need a sibling struct / untagged enum when an emit site lands — not just a new `reason` value. Refactoring deferred until then per YAGNI. - `crates/lockfile/src/snapshot_entry/tests.rs` — `!out.contains("optional")` would falsely fire on a future fixture containing `optionalDependencies:`. Tightened to check the exact key spelling (`optional: ` or `optional:\n`). - `crates/package-manager/src/build_modules/tests.rs` — stale comment pointed at `crate::executor::lifecycle::tests` which is not a valid path from this crate. Now references `pacquet_executor::select_shell` and the executor crate's `shell::tests` module. - `tasks/registry-mock/launch.mjs` — signal-forwarding logic installed `process.on('SIGINT'|'SIGTERM'|'SIGHUP', …)` handlers that overrode Node's default termination behavior, while the child-exit handler tried to re-raise via `process.kill(process.pid, signal)` — that hits our own listener in a loop. Switched to forwarding signals to the child only, and exiting with the shell-convention `128 + signo` when the child reports it was killed by a signal. - `tasks/registry-mock/launch.mjs` — stale path `crates/tasks/registry-mock/...` → `tasks/registry-mock/...`. - `tasks/registry-mock/pnpm-workspace.yaml` — comment said "Rust calls the CLI binary" but the Rust launcher now spawns `node launch.mjs`. Updated.
Summary
Closes three of the critical items in #397 by porting the corresponding
behaviors from
@pnpm/npm-lifecycle@d2d8e790andpnpm/pnpm@b4f8f47ac2:make_envmodule portsmakeEnvandthe surrounding env block in
lifecycle()(npm-lifecycle/index.js:73-104, :354-414) plus the pnpm wrapper'sextraEnvadditions (runLifecycleHook.ts:119-124). Lifecycle scripts now seenpm_lifecycle_event,npm_lifecycle_script,npm_node_execpath/NODE,npm_package_json,npm_execpath,npm_package_*(name,version, and recursiveconfig/engines/bin),npm_config_node_gyp,npm_config_user_agent,INIT_CWD,PNPM_SCRIPT_SRC_DIR, andTMPDIR(whenunsafe_permis false). The spawn now strips inherited env (env_clear()) so leftovernpm_*keys from a wrapping invocation cannot leak through.extend_pathmodule portsextendPath(npm-lifecycle/lib/extendPath.js:5-27) plus the tri-statescriptsPrependNodePathgating (:29-61). For a dep at<root>/node_modules/.pnpm/<slot>/node_modules/<pkg>,PATHnow contains the dep's own.bin, the.pnpmslot's.bin, the project root's.bin, the bundlednode-gyp-bin(when supplied),extra_bin_paths, and finally the inherited PATH.shellmodule ports the shell-selection block and the pnpm-side.bat/.cmdguard.cmd /d /s /con Windows, customscriptShellon either platform, otherwisesh -c. The Windows-batch-filescriptShellcase surfaces asERR_PNPM_INVALID_SCRIPT_SHELL_WINDOWS(matching upstream's error code).RunPostinstallHooksgrows seven new fields to surface these knobs;BuildModulespasses safe defaults (None/true/Never) for all of them — full config plumbing foruser-agent,unsafe-perm,scripts-prepend-node-path,node-gypbundling, andscript-shellare tracked as separate items in #397 (#14, #15) or follow-ups.Explicit non-goals in this PR
Three caveats called out in #397 that are deliberately deferred:
windowsVerbatimArguments(Rust equivalent:CommandExt::raw_arg) is signalled bySelectedShell.windows_verbatim_argsbut not yet applied to the spawnedCommand.@yarnpkg/shell/shellEmulator: truehas no clean Rust port; pacquet ignores the flag for now.unsafe_permuid/gid drop (Broken windows tests #14) —BuildModulespassestrue, which keeps current behavior (no TMPDIR creation, no privilege drop).Test parity
Per the project guide ("port the relevant pnpm tests too whenever they translate"), this branch ports:
test('makeEnv')from npm-lifecycle/test/index.js:97-124.extendPathordering test from npm-lifecycle/test/extendPath.test.js:5-8.runLifecycleHook() does not set npm_config env varsfrom pnpm/exec/lifecycle/test/index.ts:65-77, adapted to a file-dump model so we don't need the IPC fixture.onlyOnWindows('pnpm shows error if script-shell is .cmd')from pnpm/exec/commands/test/index.ts:509-542 and the custom-shell case from :478-508.Plus fills the gaps where upstream coverage is thin: tri-state
scriptsPrependNodePath, two-level pnpm virtual-store PATH walks,extra_bin_pathsordering, TMPDIR gating onunsafe_perm, andextra_envprecedence vsnpm_lifecycle_script.Test plan
just ready(466 tests + clippy clean)taplo format --check(clean —just readydoesn't run this, CI does)alot7-style real-install fixtures in a follow-up perf run if reviewers want to confirm no warm-install regressionWritten by an agent (Claude Code, claude-opus-4-7).
Summary by CodeRabbit