Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.

feat: lifecycle script execution and allowBuilds policy#391

Merged
zkochan merged 20 commits into
pnpm:mainfrom
Saturate:test/port-lifecycle-script-tests
May 11, 2026
Merged

feat: lifecycle script execution and allowBuilds policy#391
zkochan merged 20 commits into
pnpm:mainfrom
Saturate:test/port-lifecycle-script-tests

Conversation

@Saturate

@Saturate Saturate commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

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

Upstream refs:

What's implemented

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

What's not yet exercisable end-to-end

All 11 integration tests are known_failures because:

  1. The non-frozen install path does not write a lockfile, so tests cannot exercise --frozen-lockfile yet
  2. Bin linking (Link dependency binaries #330) is not implemented, so scripts that invoke dependency binaries fail

Once either lockfile writing or pre-generated lockfile fixtures land, the tests that use simple node scripts (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/.bin PATH walking, linkBinsOfDependencies before 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:

  • ignoreScripts config flag — not yet wired
  • pendingBuilds/ignoredBuilds persistence in .modules.yaml — not yet wired
  • Build chunks run sequentially — pnpm runs each chunk concurrently bounded by childConcurrency (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 ready
  • taplo format --check

Covers: 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

    • Added lifecycle script execution support for dependencies (preinstall, install, postinstall) during installation with proper build ordering
    • Added pnpm.allowBuilds configuration to selectively enable or disable lifecycle scripts per package
  • Tests

    • Added comprehensive test coverage for lifecycle script execution scenarios and build dependency management

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 064462ed-baa1-4cac-b1e6-bf8e306d57b8

📥 Commits

Reviewing files that changed from the base of the PR and between 4c2c4de and 37c1b4e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (23)
  • crates/cli/tests/lifecycle_scripts.rs
  • crates/executor/Cargo.toml
  • crates/executor/src/lib.rs
  • crates/executor/src/lifecycle.rs
  • crates/executor/src/lifecycle/tests.rs
  • crates/lockfile/src/package_metadata.rs
  • crates/package-manager/Cargo.toml
  • crates/package-manager/src/build_modules.rs
  • crates/package-manager/src/build_modules/tests.rs
  • crates/package-manager/src/build_sequence.rs
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/package-manager/src/build_snapshot.rs
  • crates/package-manager/src/graph_sequencer.rs
  • crates/package-manager/src/graph_sequencer/tests.rs
  • crates/package-manager/src/install.rs
  • crates/package-manager/src/install/tests.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/package-manager/src/install_without_lockfile.rs
  • crates/package-manager/src/lib.rs
  • crates/package-manifest/src/lib.rs
  • crates/reporter/src/lib.rs
  • crates/reporter/src/tests.rs
  • plans/TEST_PORTING.md
💤 Files with no reviewable changes (2)
  • crates/lockfile/src/package_metadata.rs
  • crates/package-manager/src/build_snapshot.rs
📜 Recent review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Run benchmark on ubuntu-latest
  • 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 fires pnpm:<channel> events through globalLogger, logger.debug(...), or streamParser.write(...), mirror the call site, payload, and ordering so @pnpm/cli.default-reporter parses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing into plain String or &str to preserve the distinction between validating and non-validating brands
If upstream always validates before construction of a branded string type, implement validation via TryFrom<String> and/or FromStr without providing an infallible public constructor
If upstream never validates a branded string type, expose an infallible From<String> constructor to provide type-safety without runtime validation
When upstream occasionally constructs a branded string type without validation via bare as assertion, expose a from_str_unchecked constructor 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 handwriting impl blocks
Model string-literal unions (like 'auto' | 'always' | 'never') as Rust enums 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 in CODE_STYLE_GUIDE.md covering 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 of Arc and Rc
Warnings are errors in cargo clippy --locked -- --deny warnings, and should not be silenced with `#[allow(....

Files:

  • crates/executor/src/lib.rs
  • crates/package-manager/src/install_without_lockfile.rs
  • crates/reporter/src/tests.rs
  • crates/reporter/src/lib.rs
  • crates/package-manifest/src/lib.rs
  • crates/executor/src/lifecycle/tests.rs
  • crates/package-manager/src/lib.rs
  • crates/package-manager/src/install/tests.rs
  • crates/package-manager/src/build_sequence.rs
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/package-manager/src/install.rs
  • crates/package-manager/src/build_modules/tests.rs
  • crates/package-manager/src/graph_sequencer.rs
  • crates/package-manager/src/graph_sequencer/tests.rs
  • crates/executor/src/lifecycle.rs
  • crates/package-manager/src/build_modules.rs
  • crates/cli/tests/lifecycle_scripts.rs
🧠 Learnings (4)
📚 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/lib.rs
  • crates/package-manager/src/install_without_lockfile.rs
  • crates/reporter/src/tests.rs
  • crates/reporter/src/lib.rs
  • crates/package-manifest/src/lib.rs
  • crates/executor/src/lifecycle/tests.rs
  • crates/package-manager/src/lib.rs
  • crates/package-manager/src/install/tests.rs
  • crates/package-manager/src/build_sequence.rs
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/package-manager/src/install.rs
  • crates/package-manager/src/build_modules/tests.rs
  • crates/package-manager/src/graph_sequencer.rs
  • crates/package-manager/src/graph_sequencer/tests.rs
  • crates/executor/src/lifecycle.rs
  • crates/package-manager/src/build_modules.rs
  • crates/cli/tests/lifecycle_scripts.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/reporter/src/tests.rs
  • crates/executor/src/lifecycle/tests.rs
  • crates/package-manager/src/install/tests.rs
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/package-manager/src/build_modules/tests.rs
  • crates/package-manager/src/graph_sequencer/tests.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 this repo’s Rust tests (run via `cargo nextest`), stdout/stderr from passing tests is captured and is only shown when a test fails. As a result, `eprintln!` (and similar stderr logging) in these tests should generally not be flagged as CI “noise” on the happy path; CI output should only appear for failing tests.

Applied to files:

  • crates/reporter/src/tests.rs
📚 Learning: 2026-05-07T14:24:47.105Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 391
File: crates/cli/tests/lifecycle_scripts.rs:0-0
Timestamp: 2026-05-07T14:24:47.105Z
Learning: In pnpm/pacquet CLI lifecycle tests, note that `AutoMockInstance::load_or_init` returns an anchor to a shared singleton mock registry process. If a test spawns a secondary `CommandTempCwd::init().add_mocked_registry()` (e.g., to run a reinstall with `--frozen-lockfile`), the secondary/inner `mock_instance` may be dropped safely as long as the primary/outer `mock_instance` remains in scope (so the singleton registry stays alive). Separately retain the inner `TempDir` (e.g., via a `frozen_root` binding) so the workspace lives for the duration of the command.

Applied to files:

  • crates/cli/tests/lifecycle_scripts.rs
🔇 Additional comments (27)
crates/package-manifest/src/lib.rs (1)

239-282: LGTM!

Both safe_read_package_json_from_dir and pkg_requires_build correctly implement the upstream behavior. The error handling—returning Ok(None) only for NotFound and collapsing all errors to false in the build check—matches the documented pnpm semantics.

crates/reporter/src/lib.rs (1)

448-518: LGTM!

The new pnpm:lifecycle and pnpm:ignored-scripts payload types correctly model pnpm's wire shapes. The #[serde(untagged)] on LifecycleMessage matches upstream's presence-tagged dispatch, and the camelCase field renames (depPath, exitCode, packageNames) align with what @pnpm/cli.default-reporter expects.

crates/package-manager/src/install_without_lockfile.rs (1)

185-194: LGTM!

Emitting Stage::ImportingDone after extraction and symlink linking completes matches the upstream link.ts behavior referenced in the comment. This aligns the without-lockfile path with the frozen-lockfile path's reporter event ordering.

crates/reporter/src/tests.rs (1)

468-577: LGTM!

The new wire-shape tests thoroughly validate all three LifecycleMessage variants and the IgnoredScriptsLog structure. The tests correctly verify field presence/absence and use eprintln!/dbg! for diagnostics per the repo's test-logging conventions.

crates/package-manager/src/graph_sequencer.rs (1)

1-161: LGTM!

The graph_sequencer implementation correctly ports pnpm's topological chunking algorithm. The out-degree tracking, zero-degree node selection, and cycle detection/handling all follow the upstream logic. The find_cycle BFS approach returning the longest cycle matches pnpm's behavior.

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

1-120: LGTM!

Comprehensive test coverage for graph_sequencer including edge cases (empty graph, cycles, self-loops, excluded nodes) and core behaviors (leaf-first ordering, parallel chunking, deterministic ordering). The tests follow repo conventions with dbg! output and descriptive assertion messages.

plans/TEST_PORTING.md (1)

169-189: LGTM!

Test porting progress is correctly tracked. The newly marked items align with the lifecycle execution and allowBuilds policy tests added in this PR.

crates/package-manager/src/build_sequence.rs (2)

1-182: LGTM overall!

The build_sequence implementation correctly ports pnpm's buildSequence.ts. The children-first ordering via graph_sequencer, subgraph filtering to nodes with build requirements, and cycle warning all match upstream behavior. The TODO comment for patchedDependencies appropriately tracks future work.


118-119: No action needed—the PackageKey construction is consistent. The spec.version field is a PkgVerPeer, which includes peer-dependency suffix information when present, making it equivalent to how SnapshotDepRef::resolve() constructs snapshot keys. Both code paths build PkgNameVerPeer from the same logical components (name + version-with-peers).

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

1-173: LGTM!

Comprehensive test coverage for build_sequence with clear documentation of expected behavior. The tests properly exercise empty inputs, filtering behavior, topological ordering (leaf-first), subgraph reachability, and parallel chunk grouping. Diagnostic logging via dbg! and contextual assertion messages follow the test-logging guidelines.

crates/executor/src/lifecycle.rs (4)

1-52: LGTM!

Error types are well-structured with derive_more and miette as per guidelines. The error variants cover the expected failure modes with appropriate diagnostic codes.


75-124: LGTM!

The return value correctly reflects whether any script was actually executed (via ran_any), addressing the prior review concern. The npx only-allow pnpm skip logic and binding.gyp auto-detection match pnpm semantics.


134-232: LGTM!

Reporter event ordering (Script → Stdio → Exit) matches pnpm's runLifecycleHook.ts. Joining stdout/stderr pumps after wait() ensures all output lines are emitted before the Exit event, maintaining consistent ordering.


276-288: LGTM!

The build_path_env function correctly returns OsString and uses env::join_paths(...).unwrap_or(system_path) to gracefully handle non-UTF8 PATH values, addressing the prior review concern about potential panics.

crates/executor/src/lib.rs (1)

1-3: LGTM!

Clean module declaration and explicit re-exports for the lifecycle API surface.

crates/executor/src/lifecycle/tests.rs (1)

1-245: LGTM!

Comprehensive test coverage for run_postinstall_hooks with proper diagnostic logging (dbg!, eprintln!). The tests cover success paths, failure handling, missing/malformed manifests, and reporter event ordering. Function-scoped static EVENTS mutexes correctly isolate test state.

crates/executor/Cargo.toml (1)

13-24: LGTM!

Dependencies correctly added to support the new lifecycle functionality and its tests.

crates/package-manager/Cargo.toml (1)

14-14: LGTM!

Dependencies appropriately added for the build modules lifecycle execution and policy parsing.

Also applies to: 34-34

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

207-212: LGTM!

Clear documentation of the architectural change in reporter event ordering. Moving Stage::ImportingDone emission into the install paths ensures lifecycle events render after the import progress display closes, matching upstream pnpm's behavior.

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

485-517: LGTM!

Test correctly updated to validate the new pnpm:ignored-scripts event in the expected sequence. The assertion that an empty lockfile produces no ignored builds (package_names.is_empty()) provides good coverage of the new behavior.

crates/package-manager/src/install_frozen_lockfile.rs (1)

82-91: Event ordering and ignored-scripts emission look correct.

ImportingDone is emitted before the build phase, and IgnoredScripts is emitted once with the build result payload in the expected position.

Also applies to: 96-114

crates/package-manager/src/lib.rs (1)

2-4: Module wiring and crate re-exports are clean and consistent.

The new build-related modules are exposed in a straightforward way and align with the crate’s existing public surface pattern.

Also applies to: 9-9, 22-24, 29-29

crates/package-manager/src/build_modules/tests.rs (2)

49-100: Policy tests cover the tri-state matrix well.

This set gives good confidence in default deny, explicit allow/deny, exact-version precedence, and dangerouslyAllowAllBuilds override behavior.

Also applies to: 106-131


188-221: Ignored-build behavior and reporter payload checks are solid.

You’re validating deterministic ordering, explicit-deny exclusion semantics, and IgnoredScripts payload shape in a focused way.

Also applies to: 227-260, 268-295

crates/cli/tests/lifecycle_scripts.rs (1)

183-201: Nested frozen-reinstall setup keeps the required lifetimes intact.

Retaining frozen_root and keeping the outer mock anchor alive through the test scope avoids teardown-related flakiness in the secondary command runs.

Based on learnings: AutoMockInstance::load_or_init is a shared singleton anchor, so keeping the outer mock_instance alive and retaining inner TempDir is the correct pattern.

Also applies to: 365-381, 457-467, 528-544

crates/package-manager/src/build_modules.rs (2)

111-126: Policy precedence and tri-state decision logic are implemented clearly.

Exact-version matching before package-level fallback is the right resolution order for this policy.


187-190: Deterministic ignored-build collection is a good choice.

Using a BTreeSet here keeps dedupe and lexical ordering stable for downstream reporting.

Also applies to: 201-210, 230-231


📝 Walkthrough

Walkthrough

Adds 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.

Changes

Lifecycle + BuildModules Integration

Layer / File(s) Summary
Sequencer & build graph
crates/package-manager/src/graph_sequencer.rs, crates/package-manager/src/graph_sequencer/tests.rs
Topological graph sequencer implementation and tests for ordering, cycle detection, exclusion, and determinism.
Build sequence
crates/package-manager/src/build_sequence.rs, crates/package-manager/src/build_sequence/tests.rs
Compute filtered build subgraph (nodes whose subtrees contain buildable packages) and produce leaf-first build chunks; unit tests added.
Package manifest helpers
crates/package-manifest/src/lib.rs
Safe package.json reader and pkg_requires_build helper detecting binding.gyp/.hooks and lifecycle script presence.
Policy & BuildModules
crates/package-manager/src/build_modules.rs, crates/package-manager/src/build_modules/tests.rs
Add AllowBuildPolicy parsing/tri-state check; BuildModules iterates build chunks, probes requires_build, maps snapshot keys to virtual-store paths, invokes executor lifecycle runner, and returns ignored package list.
Executor lifecycle
crates/executor/src/lifecycle.rs, crates/executor/src/lib.rs, crates/executor/Cargo.toml
Implement run_postinstall_hooks and run_lifecycle_hook: manifest read, stage selection (preinstall/install/postinstall), node-gyp fallback, PATH/INIT_CWD/PNPM_SCRIPT_SRC_DIR handling, line-pumped stdio reporter events, error mapping, plus tests and re-exports.
Reporter channels & tests
crates/reporter/src/lib.rs, crates/reporter/src/tests.rs
Add pnpm:lifecycle and pnpm:ignored-scripts LogEvent variants and corresponding payload types; add wire-shape serialization tests.
Install wiring & re-exports
crates/package-manager/src/install_frozen_lockfile.rs, crates/package-manager/src/install_without_lockfile.rs, crates/package-manager/src/lib.rs, crates/package-manager/Cargo.toml
Derive AllowBuildPolicy from manifest, emit ImportingDone before build phase, invoke BuildModules::run, emit IgnoredScripts log; expose new modules and add workspace deps.
CLI integration tests
crates/cli/tests/lifecycle_scripts.rs
Integration tests for lifecycle scripts, bin linking, allowBuilds rules, rebuilds across frozen installs, and headless installs.
Lockfile / Snapshot changes
crates/lockfile/src/package_metadata.rs, crates/package-manager/src/build_snapshot.rs
Remove requires_build field from PackageMetadata and stop populating it when building snapshots.
Tests / Documentation
plans/TEST_PORTING.md
Update TEST_PORTING to mark additional lifecycle-related tests as ported.

Sequence Diagram

sequenceDiagram
    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>
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related Issues

Possibly Related PRs

  • pnpm/pacquet#375: Modifies reporter LogEvent API and is related to lifecycle/ignored-scripts wire changes.
  • pnpm/pacquet#349: Threads reporter/ndjson into install flows; related groundwork for these changes.
  • pnpm/pacquet#355: Related install event ordering and reporter updates.

Suggested Reviewers

  • zkochan
  • KSXGitHub

Poem

🐇 I dug a burrow, found a script at night,

pre, install, post — they hopped into sight.
Bins got linked, policies took their cue,
Leaves built first, then roots followed through.
Hooray — lifecycle hops into light!

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
crates/package-manager/src/build_modules.rs (1)

39-48: 💤 Low value

Silent error swallowing may mask configuration issues.

When package.json cannot be read or parsed, from_manifest silently returns a default policy (deny all). This could hide misconfigurations where users expect their allowBuilds settings 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

📥 Commits

Reviewing files that changed from the base of the PR and between 1630ce7 and f40bf19.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • crates/cli/tests/lifecycle_scripts.rs
  • crates/executor/Cargo.toml
  • crates/executor/src/lib.rs
  • crates/executor/src/lifecycle.rs
  • crates/package-manager/Cargo.toml
  • crates/package-manager/src/build_modules.rs
  • crates/package-manager/src/build_modules/tests.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/package-manager/src/lib.rs
  • plans/TEST_PORTING.md

Comment thread crates/cli/tests/lifecycle_scripts.rs Outdated
Comment thread crates/cli/tests/lifecycle_scripts.rs Outdated
Comment thread crates/executor/src/lifecycle.rs Outdated
Comment thread crates/executor/src/lifecycle.rs Outdated
Comment thread crates/package-manager/src/install_frozen_lockfile.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
crates/cli/tests/lifecycle_scripts.rs (2)

183-189: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Capture mock_instance for frozen reinstall commands to avoid premature mock teardown.

At Line 183, Line 415, and Line 483, add_mocked_registry() is used but npmrc_info.mock_instance is 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_builds is 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 win

Add 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

📥 Commits

Reviewing files that changed from the base of the PR and between f40bf19 and d6fdf96.

📒 Files selected for processing (3)
  • crates/cli/tests/lifecycle_scripts.rs
  • crates/executor/src/lifecycle.rs
  • crates/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

zkochan added a commit to Saturate/pacquet that referenced this pull request May 7, 2026
`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.
@zkochan zkochan force-pushed the test/port-lifecycle-script-tests branch from 7cf8571 to 5f56842 Compare May 7, 2026 14:35
zkochan added a commit to Saturate/pacquet that referenced this pull request May 7, 2026
`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

Copy link
Copy Markdown
Contributor

ignoredBuilds should be covered by #332. I am actively reviewing this. I hope this PR will complete soon enough.

Bin linking should be covered by #333.

@KSXGitHub

Copy link
Copy Markdown
Contributor

Also, the DI pattern as documented in #339 and as implemented in my 3 PRs have helped plugging all coverage holes in #332.

@zkochan

zkochan commented May 7, 2026

Copy link
Copy Markdown
Member

@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.

@KSXGitHub

KSXGitHub commented May 7, 2026

Copy link
Copy Markdown
Contributor

@zkochan

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.

zkochan added a commit that referenced this pull request May 7, 2026
)

* 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.
zkochan added a commit to Saturate/pacquet that referenced this pull request May 7, 2026
`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.
@zkochan zkochan force-pushed the test/port-lifecycle-script-tests branch from 653bc3d to b965c5e Compare May 7, 2026 16:28
zkochan added a commit that referenced this pull request May 7, 2026
`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.
zkochan added a commit that referenced this pull request May 7, 2026
* 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.
zkochan added a commit to Saturate/pacquet that referenced this pull request May 7, 2026
`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.
@zkochan zkochan force-pushed the test/port-lifecycle-script-tests branch from b965c5e to 4a94ccd Compare May 7, 2026 18:11
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown

Integrated-Benchmark Report (Linux)

Scenario: Frozen Lockfile

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.495 ± 0.055 2.400 2.594 1.02 ± 0.03
pacquet@main 2.452 ± 0.060 2.393 2.581 1.00
pnpm 6.148 ± 0.054 6.064 6.244 2.51 ± 0.07
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)

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 760.7 ± 69.4 712.2 953.7 1.06 ± 0.14
pacquet@main 719.8 ± 67.5 675.4 905.2 1.00
pnpm 2629.2 ± 132.3 2502.3 2893.4 3.65 ± 0.39
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
      ]
    }
  ]
}

@Saturate

Saturate commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the context on #339, @KSXGitHub. The DI pattern makes sense for unit-testing error paths in AllowBuildPolicy::from_manifest and run_postinstall_hooks (both currently call std::fs::read_to_string and Command::new directly).

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.

@KSXGitHub

Copy link
Copy Markdown
Contributor

@Saturate So the DI pattern actually makes sense here?

Then just apply it. For now, name it Api (trait), RealApi (struct), and stuffs.

Happy to refactor to the DI pattern once the conventions are settled and shipped to main.

"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.

@KSXGitHub

Copy link
Copy Markdown
Contributor

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.

@KSXGitHub

Copy link
Copy Markdown
Contributor

The wording from #391 (comment) seems to suggest that some error paths are impossible to test normally. If so, DI makes sense.

@zkochan

zkochan commented May 9, 2026

Copy link
Copy Markdown
Member

@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.

@KSXGitHub

KSXGitHub commented May 9, 2026

Copy link
Copy Markdown
Contributor

@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.

@zkochan

zkochan commented May 9, 2026

Copy link
Copy Markdown
Member

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 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.

Saturate added 2 commits May 10, 2026 00:44
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")
Saturate and others added 12 commits May 10, 2026 00:44
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.
@zkochan zkochan force-pushed the test/port-lifecycle-script-tests branch from 4c00573 to d205d62 Compare May 9, 2026 22:49
zkochan added 3 commits May 10, 2026 01:10
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.
@zkochan

zkochan commented May 10, 2026

Copy link
Copy Markdown
Member

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants