feat: lifecycle script execution and allowBuilds policy#391
Conversation
|
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:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (23)
💤 Files with no reviewable changes (2)
📜 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). (2)
🧰 Additional context used📓 Path-based instructions (1)**/*.rs📄 CodeRabbit inference engine (AGENTS.md)
Files:
🧠 Learnings (4)📚 Learning: 2026-05-07T23:19:08.272ZApplied to files:
📚 Learning: 2026-05-01T10:01:33.766ZApplied to files:
📚 Learning: 2026-05-01T10:01:33.766ZApplied to files:
📚 Learning: 2026-05-07T14:24:47.105ZApplied to files:
🔇 Additional comments (27)
📝 WalkthroughWalkthroughAdds pnpm-like lifecycle execution, a children-first build sequencer, an AllowBuilds policy and BuildModules runner, reporter channels for lifecycle/ignored-scripts, and wires BuildModules into the frozen install path; includes unit and integration tests plus TEST_PORTING updates. ChangesLifecycle + BuildModules Integration
Sequence DiagramsequenceDiagram
participant CLI as CLI / Install (frozen)
participant PM as Package Manager (BuildModules)
participant Policy as AllowBuildPolicy
participant Exec as Executor (run_postinstall_hooks)
participant Shell as Shell (sh -c)
CLI->>PM: BuildModules::run(snapshots, packages, policy)
PM->>PM: iterate snapshot entries
PM->>PM: filter requires_build == Some(true)
PM->>Policy: check(name, version)
Policy-->>PM: Some(true) / Some(false) / None
alt allowed
PM->>Exec: run_postinstall_hooks(opts)
Exec->>Exec: read & parse package.json
Exec->>Exec: detect preinstall/install/postinstall
Exec->>Exec: build PATH, set INIT_CWD, PNPM_SCRIPT_SRC_DIR, extra env
Exec->>Shell: sh -c "preinstall"
Shell-->>Exec: exit status
Exec->>Shell: sh -c "install" (or node-gyp)
Shell-->>Exec: exit status
Exec->>Shell: sh -c "postinstall"
Shell-->>Exec: exit status
Exec-->>PM: Ok(true)
else not allowed or absent
PM-->>PM: skip or log
end
PM-->>CLI: Result<Vec<String>, BuildModulesError>
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly Related Issues
Possibly Related PRs
Suggested Reviewers
Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
crates/package-manager/src/build_modules.rs (1)
39-48: 💤 Low valueSilent error swallowing may mask configuration issues.
When
package.jsoncannot be read or parsed,from_manifestsilently returns a default policy (deny all). This could hide misconfigurations where users expect theirallowBuildssettings to apply but they don't due to a typo or malformed JSON.Consider at minimum logging a warning when the manifest exists but fails to parse.
Proposed fix to add warning logs
pub fn from_manifest(manifest_dir: &Path) -> Self { let manifest_path = manifest_dir.join("package.json"); let text = match fs::read_to_string(&manifest_path) { Ok(text) => text, - Err(_) => return Self::default(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(), + Err(e) => { + tracing::warn!(path = %manifest_path.display(), error = %e, "failed to read package.json for build policy"); + return Self::default(); + } }; let manifest: serde_json::Value = match serde_json::from_str(&text) { Ok(v) => v, - Err(_) => return Self::default(), + Err(e) => { + tracing::warn!(path = %manifest_path.display(), error = %e, "failed to parse package.json for build policy"); + return Self::default(); + } };🤖 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/package-manager/src/build_modules.rs` around lines 39 - 48, The from_manifest function currently swallows IO and parse errors and silently returns Self::default(), which hides misconfigs; update from_manifest to detect and log warnings when package.json exists but cannot be read or parsed (e.g., after fs::read_to_string and serde_json::from_str failures) instead of silently returning Self::default(), using the project's logging mechanism (e.g., log::warn! or similar) to include the manifest_dir/manifest_path and the error details before returning the default policy.
🤖 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/cli/tests/lifecycle_scripts.rs`:
- Around line 183-189: The test creates a mocked registry via
CommandTempCwd::init().add_mocked_registry() but does not retain the returned
mock_instance, so the mock server can be dropped before the command runs; update
the test to capture the result of add_mocked_registry() into a variable (e.g.,
let mock_instance = CommandTempCwd::init().add_mocked_registry();) and then call
.pacquet.with_current_dir(&workspace).with_args(["install",
"--frozen-lockfile"]).assert().success() on the CommandTempCwd value (or
otherwise keep mock_instance in scope) to ensure the mock lives through the
command execution; apply the same capture fix for the similar occurrences around
CommandTempCwd::init().add_mocked_registry() at the other noted locations (lines
referenced in the review).
- Around line 298-341: The test selectively_allow_scripts_by_allow_builds is an
incomplete port: after the first install you must perform the second install
with modified allowBuilds and assert the reporter shows ignored scripts; to fix,
in the test (selectively_allow_scripts_by_allow_builds) delete or clear
workspace/node_modules (or recreate workspace), update package_json to set
allowBuilds for both packages to false/omit the previously-allowed package, call
pacquet.with_arg("install").assert().success() again, use
allow_known_failure!(build_deps_ran(&workspace)) as before, then verify reporter
output contains the expected "ignored scripts" message and assert that the
previously-allowed package no longer has generated-by-install.js while the
denied package remains without generated files; refer to symbols pacquet,
workspace, package_json, virtual_store, allow_known_failure!, and build_deps_ran
to locate where to add these steps.
In `@crates/executor/src/lifecycle.rs`:
- Around line 191-203: The function build_path_env currently panics on non-UTF8
PATH by calling into_string().expect(...); change build_path_env(pkg_root:
&Path, extra_bin_paths: &[PathBuf]) to return an OsString instead of String,
avoid into_string and use env::join_paths(paths).unwrap_or(system_path) to
return an OsString fallback, and update any callers that used the returned
String to accept and pass the OsString directly (e.g., to .env("PATH", path_env)
which accepts OsString). Ensure you keep the same path composition logic
(own_bin, extra_bin_paths, system split_paths) but return an OsString safely
without panicking on invalid UTF-8.
- Around line 113-125: The return currently uses install_script.is_some() which
can be true even when the install script is skipped (e.g., when install_script
== "npx only-allow pnpm"); update the function to return whether a script was
actually executed rather than merely present by removing
install_script.is_some() from the final Ok expression and relying on ran_any
(and has_preinstall/has_postinstall if those reflect execution), or set a
dedicated executed_install flag when you call run_lifecycle_hook for the install
script and include that flag in the final Ok; adjust references to
install_script, ran_any, run_lifecycle_hook, has_preinstall, and has_postinstall
accordingly so the return value matches the documented "present and executed"
contract.
In `@crates/package-manager/src/install_frozen_lockfile.rs`:
- Around line 82-89: The code incorrectly uses
std::env::current_dir().unwrap_or_default() for resolving the manifest root;
update the use-sites to use the existing requester (derived from
manifest.path().parent()) instead: pass requester.as_ref() (converted to a Path)
into AllowBuildPolicy::from_manifest and set BuildModules.lockfile_dir to
requester.as_ref(), remove the unwrap_or_default() call and change the
surrounding logic to propagate the error when requester is missing (or return a
Result) rather than falling back to an empty path; keep virtual_store_dir and
modules_dir usage as-is and ensure references match the BuildModules struct
fields.
---
Nitpick comments:
In `@crates/package-manager/src/build_modules.rs`:
- Around line 39-48: The from_manifest function currently swallows IO and parse
errors and silently returns Self::default(), which hides misconfigs; update
from_manifest to detect and log warnings when package.json exists but cannot be
read or parsed (e.g., after fs::read_to_string and serde_json::from_str
failures) instead of silently returning Self::default(), using the project's
logging mechanism (e.g., log::warn! or similar) to include the
manifest_dir/manifest_path and the error details before returning the default
policy.
🪄 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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 51ba4540-2008-4552-a62f-c82de25052be
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (10)
crates/cli/tests/lifecycle_scripts.rscrates/executor/Cargo.tomlcrates/executor/src/lib.rscrates/executor/src/lifecycle.rscrates/package-manager/Cargo.tomlcrates/package-manager/src/build_modules.rscrates/package-manager/src/build_modules/tests.rscrates/package-manager/src/install_frozen_lockfile.rscrates/package-manager/src/lib.rsplans/TEST_PORTING.md
There was a problem hiding this comment.
♻️ Duplicate comments (2)
crates/cli/tests/lifecycle_scripts.rs (2)
183-189:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winCapture
mock_instancefor frozen reinstall commands to avoid premature mock teardown.At Line 183, Line 415, and Line 483,
add_mocked_registry()is used butnpmrc_info.mock_instanceis not retained. That can drop the mock server before the command completes and introduce flakiness.Suggested fix pattern
- let CommandTempCwd { pacquet: frozen_pacquet, root: frozen_root, .. } = + let CommandTempCwd { + pacquet: frozen_pacquet, + root: frozen_root, + npmrc_info: frozen_npmrc_info, + .. + } = CommandTempCwd::init().add_mocked_registry(); + let AddMockedRegistry { mock_instance: frozen_mock_instance, .. } = frozen_npmrc_info; frozen_pacquet .with_current_dir(&workspace) .with_args(["install", "--frozen-lockfile"]) .assert() .success(); - drop((root, mock_instance, frozen_root)); + drop((root, mock_instance, frozen_root, frozen_mock_instance));Also applies to: 415-421, 483-489
🤖 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/cli/tests/lifecycle_scripts.rs` around lines 183 - 189, The test is dropping the mocked registry before the install finishes because add_mocked_registry()'s npmrc_info.mock_instance isn't kept alive; update the CommandTempCwd::init().add_mocked_registry() usage to capture the returned npmrc_info.mock_instance into a local variable (e.g., let _mock = npmrc_info.mock_instance) and keep it in scope until after the .with_current_dir(...).with_args(...).assert().success() call so the mock server isn't torn down prematurely; apply the same change for the other occurrences that call add_mocked_registry() in these tests.
298-341:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift
selectively_allow_scripts_by_allow_buildsis still an incomplete upstream port.The test at Line 300 currently duplicates the first-pass scenario and misses the second install/config-change phase from the referenced pnpm case, so it does not validate the transition behavior it is named for.
As per coding guidelines, "When porting behavior from pnpm, port relevant pnpm tests too as Rust tests; use the TEST_PORTING.md plan as reference and update checkboxes as items land".
🤖 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/cli/tests/lifecycle_scripts.rs` around lines 298 - 341, The test selectively_allow_scripts_by_allow_builds currently only performs a single install and never exercises the config-change / second-install phase from the upstream pnpm test; update the test to perform two phases: (1) initial install with the manifest as currently written but with allowBuilds absent/false for the pre-and-postinstall package (so pre/postinstall are denied) and assert denied_pkg lacks generated files; (2) mutate the workspace package.json to add/enable the allowBuilds entry for "@pnpm.e2e/pre-and-postinstall-scripts-example" (or flip the config the test is intended to exercise), write the updated package_json to manifest_path, run pacquet.with_arg("install").assert().success() again, then assert the previously denied_pkg now contains generated-by-preinstall.js and/or generated-by-postinstall.js; keep the existing virtual_store/denied_pkg/allowed_pkg variables and drop((root, mock_instance)) as before.
🧹 Nitpick comments (1)
crates/cli/tests/lifecycle_scripts.rs (1)
331-338: ⚡ Quick winAdd pre-assert logs before these
assert!checks for guideline compliance.These non-
assert_eq!assertions currently run without a preceding log line, which makes CI failures harder to diagnose.As per coding guidelines, "Log before non-assert_eq! assertions, use dbg! for complex structures, and skip logging for simple scalar assert_eq! in tests".
Also applies to: 376-383, 455-466, 526-527
🤖 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/cli/tests/lifecycle_scripts.rs` around lines 331 - 338, Add diagnostic logging immediately before the non-assert_eq! assertions that check filesystem existence: log the paths/values for denied_pkg.join("generated-by-preinstall.js"), denied_pkg.join("generated-by-postinstall.js"), and allowed_pkg.join("generated-by-install.js") (e.g., using println! or dbg! for complex structures) so CI failures show context; apply the same pattern for the other assertion groups referenced (around the blocks starting at the ranges noted: 376-383, 455-466, 526-527) by inserting a brief log line before each assert! about the file path being checked.
🤖 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 `@crates/cli/tests/lifecycle_scripts.rs`:
- Around line 183-189: The test is dropping the mocked registry before the
install finishes because add_mocked_registry()'s npmrc_info.mock_instance isn't
kept alive; update the CommandTempCwd::init().add_mocked_registry() usage to
capture the returned npmrc_info.mock_instance into a local variable (e.g., let
_mock = npmrc_info.mock_instance) and keep it in scope until after the
.with_current_dir(...).with_args(...).assert().success() call so the mock server
isn't torn down prematurely; apply the same change for the other occurrences
that call add_mocked_registry() in these tests.
- Around line 298-341: The test selectively_allow_scripts_by_allow_builds
currently only performs a single install and never exercises the config-change /
second-install phase from the upstream pnpm test; update the test to perform two
phases: (1) initial install with the manifest as currently written but with
allowBuilds absent/false for the pre-and-postinstall package (so pre/postinstall
are denied) and assert denied_pkg lacks generated files; (2) mutate the
workspace package.json to add/enable the allowBuilds entry for
"@pnpm.e2e/pre-and-postinstall-scripts-example" (or flip the config the test is
intended to exercise), write the updated package_json to manifest_path, run
pacquet.with_arg("install").assert().success() again, then assert the previously
denied_pkg now contains generated-by-preinstall.js and/or
generated-by-postinstall.js; keep the existing
virtual_store/denied_pkg/allowed_pkg variables and drop((root, mock_instance))
as before.
---
Nitpick comments:
In `@crates/cli/tests/lifecycle_scripts.rs`:
- Around line 331-338: Add diagnostic logging immediately before the
non-assert_eq! assertions that check filesystem existence: log the paths/values
for denied_pkg.join("generated-by-preinstall.js"),
denied_pkg.join("generated-by-postinstall.js"), and
allowed_pkg.join("generated-by-install.js") (e.g., using println! or dbg! for
complex structures) so CI failures show context; apply the same pattern for the
other assertion groups referenced (around the blocks starting at the ranges
noted: 376-383, 455-466, 526-527) by inserting a brief log line before each
assert! about the file path being checked.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 7a20ef85-c8f3-47db-a09d-cea3781bc368
📒 Files selected for processing (3)
crates/cli/tests/lifecycle_scripts.rscrates/executor/src/lifecycle.rscrates/package-manager/src/build_modules.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- crates/executor/src/lifecycle.rs
- crates/package-manager/src/build_modules.rs
`InstallFrozenLockfile` was reading both `AllowBuildPolicy` and `BuildModules.lockfile_dir` from `std::env::current_dir().unwrap_or_default()`, which is wrong when the process CWD differs from the project root and silently masks CWD failures via the empty-path fallback. Pass the existing `requester` (derived from `manifest.path().parent()`) instead. Resolves the install_frozen_lockfile.rs:89 review comment on PR pnpm#391.
7cf8571 to
5f56842
Compare
`InstallFrozenLockfile` was reading both `AllowBuildPolicy` and `BuildModules.lockfile_dir` from `std::env::current_dir().unwrap_or_default()`, which is wrong when the process CWD differs from the project root and silently masks CWD failures via the empty-path fallback. Pass the existing `requester` (derived from `manifest.path().parent()`) instead. Resolves the install_frozen_lockfile.rs:89 review comment on PR pnpm#391.
|
@KSXGitHub keep in mind that this is not yet production code. It doesn't need to be perfect as long as the benchmarks are good. It is OK to ship ugly code faster and file an issue for future refactor. |
Well. "Nothing is more permanent than a temporary solution" or so they say. Also. Strict tests and strict code are important in this age of AI code generation. They make it less likely for AI to introduce bugs and subtle mistakes. Fast iteration and quick prototyping is important, yes, but we're no longer writing code manually, do we? The cost of perfect code is on the AI whilst the toll of imperfect code is on the human reviewers. That is what I think. |
) * ci(integrated-benchmark): post the comment from a workflow_run job The benchmark comment never landed on fork PRs (e.g. #391). The original workflow ran on `pull_request`, which means GitHub gives it a read-only `GITHUB_TOKEN` when the PR comes from a fork — `peter-evans/create-or-update-comment` would 403 trying to write back. The workflow guarded the write with `if: github.event.pull_request.head.repo.full_name == github.repository`, so it silently skipped instead of failing. Switch to the standard two-stage pattern: 1. `integrated-benchmark.yml` runs the benchmark and uploads `SUMMARY.md`, the PR number, and the runner OS as a single artifact. It no longer needs `issues` / `pull-requests` write permissions. 2. `integrated-benchmark-comment.yml` (new) is triggered on `workflow_run` of `Integrated-Benchmark` and runs in the base repository's privilege context, where it can post the comment back to a fork PR. It downloads the artifact via the GitHub API (`actions/download-artifact@v8` with `run-id` + `github-token`), sanitises both untrusted values (the artifact comes from a fork-controlled run), then runs the same find/create-or-update sequence the original workflow used. Same gating as before: only success-path runs of `pull_request` events post a comment. Comments from same-repo PRs continue to work; fork PRs now also get them. * ci(integrated-benchmark): resolve PR number from trusted workflow_run trigger Address CodeRabbit's PR-redirection concern on #398: a fork-controlled artifact must not be the source of truth for which PR receives the comment. Move the PR-number resolution into the comment workflow, sourced from `workflow_run` event metadata that GitHub itself fills in: - Same-repo PRs: read directly from `github.event.workflow_run.pull_requests[0].number`. - Fork PRs (where that array is empty): query `gh api repos/$REPO/commits/$HEAD_SHA/pulls` against the BASE repo, using `workflow_run.head_sha` from the same trusted payload. CodeRabbit's suggested fix used `pull_requests[0].number` unconditionally, which would have broken the fork-PR case this workflow exists to fix — GitHub's docs explicitly state that field is empty for fork-triggered runs. Drop `pr-number.txt` from the uploaded artifact entirely. The runner OS is also no longer carried; the matrix only runs on Linux today, and the comment-matching string is hard-coded to `Integrated-Benchmark Report (Linux)`. If the matrix expands later, both halves need updating together.
`InstallFrozenLockfile` was reading both `AllowBuildPolicy` and `BuildModules.lockfile_dir` from `std::env::current_dir().unwrap_or_default()`, which is wrong when the process CWD differs from the project root and silently masks CWD failures via the empty-path fallback. Pass the existing `requester` (derived from `manifest.path().parent()`) instead. Resolves the install_frozen_lockfile.rs:89 review comment on PR pnpm#391.
653bc3d to
b965c5e
Compare
`gh api ... --paginate --jq` runs the jq filter per page, so a query
that returns multiple pages emits one JSON array per page on stdout,
not a single combined array. Reproduced locally with per_page=2:
$ gh api '.../pulls?state=open&per_page=2' --paginate \
--jq '[.[] | .number]'
[399,391]
[385,337]
[333,332]
...
Today pacquet has under 100 open PRs so a single page suffices and
the bug is dormant; past that threshold the downstream
`jq 'length'` would read each line as a separate input and produce
multiple values like `2\n2\n2\n1`, which fails both the `count == 0`
and `count == 1` checks and bottoms out in the "ambiguous" error.
Fix: emit one PR per line with `--paginate --jq '.[]'` so the stream
spans pages cleanly, then `jq -s` slurps the whole thing back into a
single array. `--arg sha` keeps the head SHA out of the jq program
text. Verified with per_page=2 against PR #391's head SHA: returns
`[391]` as expected.
Reported by Copilot on #399.
* ci(integrated-benchmark): list open PRs to find fork-PR head SHA The fallback `gh api repos/$REPO/commits/$HEAD_SHA/pulls` lookup only returns PRs whose head commit lives on the base repo — for fork PRs the head SHA exists on the fork (e.g. `Saturate/pacquet`), not the base, so that endpoint returns `[]`. The Stage-2 comment workflow on PR #391 failed at this step: > ##[error]no open PR matches head_sha=b965c5e... in pnpm/pacquet Switch to listing open PRs in the base repo and filtering by `head.sha`. The PRs API records each PR's head SHA on the base-repo side regardless of where the head branch lives, so this works for both same-repo PRs and fork PRs through one code path. Same-repo PRs still bypass the API call entirely via the trusted `workflow_run.pull_requests[0].number` shortcut. The fallback only runs when that array is empty (the fork-PR case). * ci(integrated-benchmark): fix paginated jq aggregation `gh api ... --paginate --jq` runs the jq filter per page, so a query that returns multiple pages emits one JSON array per page on stdout, not a single combined array. Reproduced locally with per_page=2: $ gh api '.../pulls?state=open&per_page=2' --paginate \ --jq '[.[] | .number]' [399,391] [385,337] [333,332] ... Today pacquet has under 100 open PRs so a single page suffices and the bug is dormant; past that threshold the downstream `jq 'length'` would read each line as a separate input and produce multiple values like `2\n2\n2\n1`, which fails both the `count == 0` and `count == 1` checks and bottoms out in the "ambiguous" error. Fix: emit one PR per line with `--paginate --jq '.[]'` so the stream spans pages cleanly, then `jq -s` slurps the whole thing back into a single array. `--arg sha` keeps the head SHA out of the jq program text. Verified with per_page=2 against PR #391's head SHA: returns `[391]` as expected. Reported by Copilot on #399.
`InstallFrozenLockfile` was reading both `AllowBuildPolicy` and `BuildModules.lockfile_dir` from `std::env::current_dir().unwrap_or_default()`, which is wrong when the process CWD differs from the project root and silently masks CWD failures via the empty-path fallback. Pass the existing `requester` (derived from `manifest.path().parent()`) instead. Resolves the install_frozen_lockfile.rs:89 review comment on PR pnpm#391.
b965c5e to
4a94ccd
Compare
Integrated-Benchmark Report (Linux)Scenario: Frozen Lockfile
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.49470932314,
"stddev": 0.05534915013479373,
"median": 2.49087231924,
"user": 2.43428434,
"system": 3.40643104,
"min": 2.4004308127400003,
"max": 2.59441972874,
"times": [
2.49211924274,
2.53490448974,
2.4004308127400003,
2.47193156174,
2.59441972874,
2.53293984374,
2.44808193074,
2.52950034774,
2.48962539574,
2.45313987774
]
},
{
"command": "pacquet@main",
"mean": 2.45212469324,
"stddev": 0.05991222894326608,
"median": 2.45645814174,
"user": 2.3796690399999996,
"system": 3.3944475400000003,
"min": 2.39336539974,
"max": 2.58141792674,
"times": [
2.48085335974,
2.48170405674,
2.44517443974,
2.39451110174,
2.48143357374,
2.58141792674,
2.39336539974,
2.39859385474,
2.4677418437400003,
2.39645137574
]
},
{
"command": "pnpm",
"mean": 6.1476544102399995,
"stddev": 0.054082338247983024,
"median": 6.14566121074,
"user": 8.92114464,
"system": 4.517572840000001,
"min": 6.064333211739999,
"max": 6.2439326167399996,
"times": [
6.124758851739999,
6.1295156317399995,
6.11624053174,
6.064333211739999,
6.2439326167399996,
6.180544610739999,
6.1874792117399995,
6.1841127467399994,
6.08381989974,
6.16180678974
]
}
]
}Scenario: Frozen Lockfile (Hot Cache)
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.76065586512,
"stddev": 0.06939933560444626,
"median": 0.7413738969200001,
"user": 0.26714208,
"system": 1.3789870200000003,
"min": 0.7122073954200001,
"max": 0.9536680794200001,
"times": [
0.9536680794200001,
0.74341517542,
0.75661199842,
0.7583229074200001,
0.7548829254200001,
0.7296791544200001,
0.7302943054200001,
0.7122073954200001,
0.7393326184200001,
0.72814409142
]
},
{
"command": "pacquet@main",
"mean": 0.7198167418200001,
"stddev": 0.06748822150677859,
"median": 0.7018302499200001,
"user": 0.25439818,
"system": 1.35360982,
"min": 0.6753849664200001,
"max": 0.9052415194200001,
"times": [
0.7400007834200001,
0.68179527642,
0.6948395314200001,
0.70622127142,
0.6753849664200001,
0.7034763664200001,
0.68715190142,
0.9052415194200001,
0.70387166842,
0.7001841334200001
]
},
{
"command": "pnpm",
"mean": 2.62922217372,
"stddev": 0.1323056835240401,
"median": 2.57600996792,
"user": 3.0940749799999994,
"system": 2.2450080199999998,
"min": 2.50232658442,
"max": 2.89338164942,
"times": [
2.76619589542,
2.5116436224200003,
2.50232658442,
2.89338164942,
2.6289161504200003,
2.55172318442,
2.57344441242,
2.57857552342,
2.75922142342,
2.52679329142
]
}
]
} |
|
Thanks for the context on #339, @KSXGitHub. The DI pattern makes sense for unit-testing error paths in Since #339 hasn't landed and the final names are still being decided, this PR uses direct calls for now. Happy to refactor to the DI pattern once the conventions are settled and shipped to main. |
|
@Saturate So the DI pattern actually makes sense here? Then just apply it. For now, name it
"Shipped to main" means that there shall be a refactor PR to touch many parts of the code, including this one. Anyway, just do DI now. If the names are unfit, they shall be renamed later in a different PR. |
|
The DI issue also has the "When dependency injection is not needed" section as well. The AI implementing this should take it into consideration. I haven't read this code in details to know whether DI would actually be required. |
|
The wording from #391 (comment) seems to suggest that some error paths are impossible to test normally. If so, DI makes sense. |
|
@KSXGitHub the postinstall tests in the pnpm/pnpm repository don't use any mocking. They test real packages with install scripts from the registry-mock and the public registry. So, I am not sure why dependency injection is needed here. |
|
@zkochan We are porting with AI. AIs make mistakes. DI is only used for unreachable edge cases. Those edge cases are unreachable even in pnpm/pnpm. |
The instructions are to do exact copy of pnpm/pnpm including tests. If there are edge cases that are not covered in pnpm, there is no requirement to cover them in the rust rewrite. |
Port 11 upstream pnpm lifecycle script tests from installing/deps-installer/test/install/lifecycleScripts.ts and installing/deps-restorer/test/index.ts as known_failures in a new crates/cli/tests/lifecycle_scripts.rs file. All tests early-return at the `build_deps_ran` stub because pacquet does not yet execute lifecycle scripts (preinstall, install, postinstall) during install. The assertions after the stub verify the behavior that the future implementation must satisfy. Upstream ref: https://github.com/pnpm/pnpm/blob/7e91e4b35f/installing/deps-installer/test/install/lifecycleScripts.ts Covers: pnpm/pacquet#299 (Stage 1, "Support building dependencies")
Add lifecycle script execution (preinstall, install, postinstall) to both frozen-lockfile and non-frozen install paths. The executor crate gains a `run_postinstall_hooks` function that reads a package's `package.json`, runs preinstall/install/postinstall in order, detects `binding.gyp` for implicit install scripts, and sets the correct environment (PATH with `.bin` dirs, INIT_CWD, PNPM_SCRIPT_SRC_DIR). Ports pnpm's `runPostinstallHooks` from https://github.com/pnpm/pnpm/blob/7e91e4b35f/exec/lifecycle/src/index.ts The package-manager crate gains two build strategies: - `BuildModules`: uses lockfile `requires_build` metadata (frozen path) - `BuildModulesByScanning`: walks the virtual store checking each package.json for scripts (non-frozen path, warns on failures since bin linking is not yet available) The `run_install_scripts` test is promoted from known_failures to a real passing test. The remaining 10 lifecycle tests stay as known_failures because they depend on bin linking (pnpm#330). Covers: pnpm/pacquet#299 (Stage 1, "Support building dependencies")
pnpm 11 denies lifecycle scripts by default. Packages must be explicitly listed in allowBuilds to run. Previously our policy returned Some(true) when no allowBuilds key was present, which allowed all builds and diverged from upstream. Also add support for dangerouslyAllowAllBuilds which bypasses the policy entirely.
pnpm never scans the filesystem to find packages with lifecycle scripts. It always uses the dep graph (from lockfile or resolution). The BuildModulesByScanning approach was an invention that diverged from upstream behavior: it swallowed errors and ran scripts in an unpredictable order. Lifecycle scripts now only run in the frozen-lockfile path where lockfile metadata (requires_build) is available. The non-frozen install path does not run lifecycle scripts until it gains a dep graph (lockfile writing or resolution graph). All 11 integration tests are now known_failures. They will be promoted once the non-frozen path writes lockfiles or once pre-generated lockfile fixtures are added.
- Warn when package.json exists but fails to parse (instead of silently defaulting to deny-all) - Return OsString from build_path_env to avoid panic on non-UTF-8 PATH - Fix run_postinstall_hooks return value to reflect actual execution (not just script presence) - Bind frozen-reinstall CommandTempCwd to a variable so the mock registry stays alive through the command execution
Port pnpm's `buildSequence` and `graphSequencer` so packages with `requires_build` run children-first. Previously `BuildModules` iterated snapshots in HashMap order, so a postinstall that shells out to a sibling-dep CLI could run before that dep had built — failing non-deterministically. `build_sequence` walks the dep graph from each importer's direct dependencies, keeps only nodes whose subtree contains a build node, and hands the subgraph to `graph_sequencer` to produce topologically ordered chunks. `BuildModules` now iterates chunks in order; within a chunk members remain independent (concurrency is still a TODO). References: - building/during-install/src/buildSequence.ts - deps/graph-sequencer/src/index.ts in https://github.com/pnpm/pnpm/blob/80037699fb/
Pre-existing alignment drift introduced in a1dc3fd. `just ready` only runs `cargo fmt`, not `taplo format`, so it was missed locally; CI's `taplo format --check` caught it.
`InstallFrozenLockfile` was reading both `AllowBuildPolicy` and `BuildModules.lockfile_dir` from `std::env::current_dir().unwrap_or_default()`, which is wrong when the process CWD differs from the project root and silently masks CWD failures via the empty-path fallback. Pass the existing `requester` (derived from `manifest.path().parent()`) instead. Resolves the install_frozen_lockfile.rs:89 review comment on PR pnpm#391.
The CODE_STYLE_GUIDE.md "When or when not to log during tests" rule
requires logging context before bare `assert!(...)` calls so failures
print more than just the expression text. Add `dbg!` of the inspected
value and assertion messages with `{value:?}` so a future regression
shows the full structure instead of "assertion failed".
Match upstream's NDJSON wire format for the build phase so @pnpm/cli.default-reporter parses pacquet's lifecycle output the same way it parses pnpm's. - Add `LogEvent::Lifecycle(LifecycleLog)` with the three upstream message shapes (Script, Stdio, Exit) as a `#[serde(untagged)]` union, mirroring `core/core-loggers/src/lifecycleLogger.ts`. - Add `LogEvent::IgnoredScripts(IgnoredScriptsLog)` carrying `packageNames` (camelCase), mirroring `ignoredScriptsLogger.ts`. - `pacquet-executor` depends on `pacquet-reporter`. `run_postinstall_hooks` / `run_lifecycle_hook` are now generic over `R: Reporter`. The hook switches from `Stdio::inherit()` to `Stdio::piped()` and reads each stream on its own thread, emitting one `Stdio` event per line. A `Script` event fires before spawn, `Exit` fires after wait — same ordering as `runLifecycleHook.ts:102/165`. - `BuildModules::run::<R>` collects sorted (peer-stripped) keys of packages that hit the "not in allowBuilds" branch and returns them as `Vec<String>`. Explicit `false` is silently skipped, matching upstream's switch in `building/during-install/src/index.ts:88-101`. - `InstallFrozenLockfile::run` emits one `pnpm:ignored-scripts` event with the returned list (always, even when empty) — mirrors the unconditional emit at `installing/deps-installer/src/install/index.ts:414`. Tests: - Wire-shape tests for both new variants pin the JSON output. - Recording-fake tests for `run_postinstall_hooks` cover Script→Stdio→Exit ordering and non-zero exit propagation. - Recording-fake tests for `BuildModules::run` cover the ignored-builds return value (sorted, peer-stripped, excludes explicit-deny). - Updated `install_emits_pnpm_event_sequence` to expect the new `IgnoredScripts` event between `Stats` and `ImportingDone`. Addresses items pnpm#6 and pnpm#8 from pnpm#397. Upstream refs at `pnpm/pnpm@80037699fb`: - core/core-loggers/src/lifecycleLogger.ts - core/core-loggers/src/ignoredScriptsLogger.ts - exec/lifecycle/src/runLifecycleHook.ts - building/during-install/src/index.ts - installing/deps-installer/src/install/index.ts
Per CODE_STYLE_GUIDE.md: log before non-assert_eq! assertions so CI failures are easier to diagnose.
…pm on all stages Two fixes from the lifecycle gap audit (pnpm#397): - Malformed package.json now returns Ok(false) instead of a hard error, matching upstream safeReadPackageJsonFromDir which returns null on missing OR malformed manifests. - The "npx only-allow pnpm" skip now applies to preinstall and postinstall too, not just install. Upstream skips it for every stage at runLifecycleHook.ts:100.
…on_from_dir Mirror upstream's split between manifest reading and lifecycle dispatch: - Add safe_read_package_json_from_dir to pacquet-package-manifest, matching safeReadPackageJsonFromDir at https://github.com/pnpm/pnpm/blob/80037699fb/pkg-manifest/reader/src/index.ts#L48 (returns Ok(None) only on ENOENT; other IO errors and JSON parse errors propagate). - run_postinstall_hooks now calls the helper instead of inlining the IO and parse, matching upstream runPostinstallHooks at https://github.com/pnpm/pnpm/blob/80037699fb/exec/lifecycle/src/index.ts#L22 - LifecycleScriptError::ReadManifest wraps PackageManifestError to cover both IO and parse failures. Reverts the malformed-JSON Ok(false) from 18c624c. The audit (pnpm#397 item 7) claimed safeReadPackageJsonFromDir returns null on malformed JSON, but the upstream try/catch only swallows ENOENT — BAD_PACKAGE_JSON re-throws. New tests cover the missing-manifest Ok(false) path and the malformed- manifest LifecycleScriptError::ReadManifest path with PackageManifestError::Serialization as the source.
… pure new + IO wrapper Match upstream's separation between policy logic and config sourcing: - Add AllowBuildPolicy::new(rules, dangerously_allow_all) — pure constructor mirroring createAllowBuildFunction(opts) at https://github.com/pnpm/pnpm/blob/80037699fb/building/policy/src/index.ts - from_manifest is now a thin wrapper that reads package.json, extracts the pnpm section, and delegates to new. The 7 policy-logic tests now drive new(...) directly with in-memory rule maps, matching upstream's tests at https://github.com/pnpm/pnpm/blob/80037699fb/building/policy/test/index.ts that drive createAllowBuildFunction(opts) decoupled from Config parsing. The from_manifest path keeps a missing-manifest test plus a new from_manifest_parses_pnpm_section integration test for the wrapper.
4c00573 to
d205d62
Compare
Mirrors upstream's pkgRequiresBuild from https://github.com/pnpm/pnpm/blob/80037699fb/building/pkg-requires-build/src/index.ts Decides whether a package directory needs a build pass by inspecting the extracted package's manifest scripts (preinstall/install/postinstall) and the presence of binding.gyp / .hooks/. Reuses safe_read_package_json_from_dir so a missing or malformed manifest collapses to false. Used by the next commit to compute requires_build from disk instead of trusting an optional lockfile field that pnpm v11 no longer writes.
…kfile field Match upstream's source-of-truth for whether a package needs a build pass: the extracted package's own files, not the lockfile. Real pnpm-generated v9 lockfiles rarely include requiresBuild for individual packages — upstream computes it on the fly via pkgRequiresBuild after extraction (https://github.com/pnpm/pnpm/blob/80037699fb/worker/src/start.ts#L147), attaches it to the in-memory dep node, and gates the build pipeline on that. Pacquet was reading the optional lockfile field directly, so installs against any lockfile without requiresBuild silently skipped every script — including for fsevents, esbuild, native deps, etc. Changes: - Drop requires_build: Option<bool> from PackageMetadata in pacquet-lockfile. Existing lockfiles with requiresBuild lines deserialize fine — serde ignores unknown fields by default. - BuildModules::run computes a requires_build_map by walking each snapshot's pkg_dir under the virtual store and calling pkg_requires_build (added in the previous commit). The map is used both for the per-package gate and as input to build_sequence. - build_sequence now takes a &HashMap<PackageKey, bool> instead of &HashMap<PackageKey, PackageMetadata>; the metadata field it consulted no longer exists, and the map is the right shape for what it actually needs. - BuildModules drops the now-unused packages field. - Tests in build_modules and build_sequence updated to drive the new shape: build_sequence tests pass an in-memory map directly; build_modules tests materialize a real package.json fixture under the virtual store path so pkg_requires_build returns true.
Move the importing_done emit out of the install.rs orchestrator and into the install paths, fired right after extraction and symlink linking finish. Mirrors upstream link.ts:167-170, where the stage marker closes the import progress display before any build phase begins: https://github.com/pnpm/pnpm/blob/80037699fb/installing/deps-installer/src/install/link.ts#L167 Previously install.rs emitted importing_done after the entire frozen install path returned — including BuildModules — so reporters would keep the import progress bar open while lifecycle scripts ran, and lifecycle output rendered before importing was marked done. InstallFrozenLockfile now emits importing_done between SymlinkDirectDependencies and BuildModules. InstallWithoutLockfile emits it after the writer task drains (its end-of-import boundary; this path does not run scripts today). install.rs no longer emits importing_done itself. Updates install_emits_pnpm_event_sequence to expect the new importing_done position before pnpm:ignored-scripts.
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
Summary
pacquet-executor: preinstall, install, postinstall in correct order,binding.gypauto-detection,INIT_CWD/PNPM_SCRIPT_SRC_DIR/PATH environment setupallowBuildsbuild policy matching pnpm 11's default-deny behavior, withdangerouslyAllowAllBuildsescape hatchbuildSequence+graphSequencerso packages withrequires_buildrun children-firstpnpm:lifecycleandpnpm:ignored-scriptsreporter channels — script start/stdio/exit events flow through the same NDJSON pipeline@pnpm/cli.default-reporterconsumes from upstream pnpmknown_failuresUpstream refs:
runPostinstallHooksrunLifecycleHookcreateAllowBuildFunctionbuildSequencegraphSequencerlifecycleLoggerignoredScriptsLoggerWhat's implemented
run_postinstall_hooksinpacquet-executorfaithfully ports pnpm's hook runner. The hook is now generic overR: Reporterand emitspnpm:lifecycleevents through the standard pacquet reporter pipeline:Scriptbefore each spawn, oneStdioevent per stdout/stderr line read throughStdio::piped()byline buffering, andExitafter the script returnsAllowBuildPolicyreadspnpm.allowBuildsandpnpm.dangerouslyAllowAllBuildsfrompackage.jsonwith correct tri-state semantics (allow/deny/ignored)allowBuildsto run their scriptsBuildModules::run::<R>runs lifecycle scripts forrequires_build: truepackages in the frozen-lockfile path and returns the sorted (peer-stripped) list of packages whose scripts were skipped because they weren't inallowBuilds.InstallFrozenLockfile::runemits onepnpm:ignored-scriptsevent with that list (always, even when empty), mirroring upstreambuild_sequencewalks the dep graph from each importer, keeps only nodes whose subtree contains a build node, and feeds that subgraph intograph_sequencerto produce topologically ordered chunks.BuildModulesiterates the chunks in order so children build before parentsAllowBuildPolicyandBuildModules.lockfile_diruse the existingrequesterfield (derived frommanifest.path().parent()) instead ofstd::env::current_dir()LogEvent::LifecycleandLogEvent::IgnoredScriptsvariants with wire-shape testsrun_postinstall_hooks(Script→Stdio→Exit ordering, non-zero exit propagation) and forBuildModules::run(sorted ignored-builds, explicit-deny exclusion). Existinginstall_emits_pnpm_event_sequenceupdated to expect the newIgnoredScriptsevent betweenStatsandImportingDonegraph_sequencer, andbuild_sequenceWhat's not yet exercisable end-to-end
All 11 integration tests are
known_failuresbecause:--frozen-lockfileyetOnce either lockfile writing or pre-generated lockfile fixtures land, the tests that use simple
nodescripts (no dependency bins) can be promoted. The rest need #330.Known limitations / follow-ups
The remaining gaps vs upstream pnpm are tracked in #397, including missing
npm_*lifecycle env vars, ancestor-node_modules/.binPATH walking,linkBinsOfDependenciesbefore scripts, Windows shell selection, optional-dep failure tolerance,patchedDependencies,isBuilt/side-effects-cache skipping, build concurrency, and a few minor items.This PR also still has:
ignoreScriptsconfig flag — not yet wiredpendingBuilds/ignoredBuildspersistence in.modules.yaml— not yet wiredchildConcurrency(perf-only)Test plan
cargo test -p pacquet-package-manager build_modules::tests(15 unit tests, including the new ignored-builds tests)cargo test -p pacquet-package-manager build_sequence::tests(5 unit tests)cargo test -p pacquet-package-manager graph_sequencer::tests(8 unit tests)cargo test -p pacquet-executor lifecycle::tests(3 unit tests, recording-fake reporter)cargo test -p pacquet-reporter lifecycle_event ignored_scripts(2 wire-shape tests)cargo test -p pacquet-cli --test lifecycle_scripts(11 known_failures)just readytaplo format --checkCovers: pnpm/pnpm#11633 (Stage 1, "Support building dependencies")
Written by an agent (Claude Code, claude-opus-4-7).
Summary by CodeRabbit
Release Notes
New Features
pnpm.allowBuildsconfiguration to selectively enable or disable lifecycle scripts per packageTests