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

Lifecycle script subsystem: gaps vs upstream pnpm #397

@zkochan

Description

@zkochan

Audit of pacquet's dependency-build / lifecycle-script subsystem against pnpm v11 (commit b4f8f47ac2). The recently-landed work (PR #391) covers the policy semantics (pnpm.allowBuilds, default-deny, dangerouslyAllowAllBuilds), the hook ordering (preinstall → install → postinstall), and topological build ordering via buildSequence / graphSequencer. PR #333 added LinkVirtualStoreBins, which populates each virtual-store slot's node_modules/.bin before BuildModules runs (resolving the original item #3). The items below are the remaining divergences, ranked by how badly they break real installs.

pnpm v11 still depends on @pnpm/npm-lifecycle@1100.0.0-1 (the fork lives at pnpm/npm-lifecycle@d2d8e790). Several items below cite that external repo rather than pnpm/pnpm.

The relevant pacquet code lives in:

  • crates/package-manager/src/build_modules.rs
  • crates/package-manager/src/build_sequence.rs
  • crates/executor/src/lifecycle.rs

Critical — common installs will fail

1. Lifecycle env vars almost entirely missing ✅ Addressed in ff6edea

PR #418 added a make_env module porting makeEnv and the surrounding env block from @pnpm/npm-lifecycle@d2d8e790 (index.js:73-104 + :354-414) plus the pnpm wrapper's extraEnv additions (runLifecycleHook.ts:119-124). Lifecycle scripts now see npm_lifecycle_event, npm_lifecycle_script, npm_node_execpath / NODE, npm_package_json, npm_execpath, npm_config_node_gyp, npm_package_* (name, version, recursive config/engines/bin), npm_config_user_agent, INIT_CWD, PNPM_SCRIPT_SRC_DIR, and TMPDIR under <wd>/node_modules/.tmp when unsafe_perm is false. The spawn now strips inherited env (env_clear()) so leftover npm_* keys from a wrapping invocation cannot leak through.

Originally:

pacquet sets only PATH, INIT_CWD, PNPM_SCRIPT_SRC_DIR. Upstream goes through @pnpm/npm-lifecycle's lifecycle()makeEnv and sets at minimum npm_lifecycle_event, npm_lifecycle_script, npm_node_execpath / NODE, npm_package_json, npm_execpath, npm_config_node_gyp, npm_package_* for name / version / config / engines / bin, npm_config_user_agent, and TMPDIR under node_modules/.tmp when !unsafePerm.

Impact: native build deps (sharp, node-sass, node-pre-gyp, bcrypt, anything calling node-gyp) read these and fail without them. Common postinstalls also read npm_package_version / npm_package_config_*. Related: pacquet's Command::new("sh") inherits the parent env unfiltered, so leftover npm_* vars from any wrapping invocation leak through. Upstream makeEnv strips them.

2. PATH does not walk ancestor node_modules/.bin ✅ Addressed in ff6edea

PR #418 added an extend_path module porting extendPath from @pnpm/npm-lifecycle@d2d8e790 (lib/extendPath.js:5-27). For a dep at <root>/node_modules/.pnpm/<slot>/node_modules/<pkg>, PATH now contains the dep's own .bin, the .pnpm slot's .bin, the project root's .bin, the (currently unbundled) node-gyp-bin slot, extra_bin_paths, and finally the inherited PATH. The tri-state scriptsPrependNodePath enum is in place but config-plumbing for it is its own item — see #15.

Originally:

pacquet only prepends pkg_root/node_modules/.bin plus extra_bin_paths. Upstream extendPath walks every ancestor node_modules/.bin segment from wd upward, plus a bundled node-gyp-bin, plus extraBinPaths, plus optionally the dir of process.execPath when scriptsPrependNodePath is set.

Impact: inside the virtual store, a dep's own pkg_root/node_modules/.bin is now populated by LinkVirtualStoreBins (PR #333), but the tools many native postinstalls need (node-gyp, node-pre-gyp, sibling-dep CLIs surfaced into the project root's .bin) live higher up the tree. Without the ancestor walk those still can't be resolved.

4. Hardcoded sh -c on Windows ✅ Addressed in ff6edea

PR #418 added a shell module porting the shell-selection block from @pnpm/npm-lifecycle@d2d8e790 (index.js:241-252) plus the pnpm-side .bat / .cmd guard (runLifecycleHook.ts:63-71). Resolution order: custom script_shell wins, otherwise cmd /d /s /c (with %ComSpec% lookup) on Windows, otherwise sh -c. A Windows-side .bat / .cmd script_shell now surfaces as ERR_PNPM_INVALID_SCRIPT_SHELL_WINDOWS (the same diagnostic code upstream uses).

Two carve-outs called out in the PR description and left for follow-ups:

  • windowsVerbatimArguments (Rust equivalent: CommandExt::raw_arg) is signalled by SelectedShell.windows_verbatim_args but not yet applied to the spawned Command.
  • @yarnpkg/shell / shellEmulator: true has no clean Rust port; pacquet ignores the flag.

Originally:

lifecycle.rs:145 hardcodes sh -c. Upstream picks the shell based on platform and config: cmd /d /s /c on Windows, @yarnpkg/shell when shellEmulator is set, or a custom scriptShell.

Impact: scripts cannot run at all on Windows. scriptShell and shellEmulator config are silently ignored on every platform.

Moderate — less common cases or specific configs

5. allowBuilds config source and spec parser ✅ Addressed in 6af026f + eefff34

PR #425 moved AllowBuildPolicy off package.json onto pnpm-workspace.yaml. Config now carries allow_builds: HashMap<String, bool> and dangerously_allow_all_builds: bool, populated by WorkspaceSettings::apply_to from the matching yaml keys. The old AllowBuildPolicy::from_manifest is gone; the policy is constructed via AllowBuildPolicy::from_config(&Config) at the install entry point.

PR #428 then landed the spec parser:

  • New pacquet_package_manager::version_policy module porting upstream's expandPackageVersionSpecs. Supports bare names (foo, @scope/foo), exact versions (foo@1.0.0), and version unions (foo@1.0.0 || 2.0.0). Whitespace around || and within each version is trimmed (matches Node-semver's valid()). Two upstream error codes propagate: ERR_PNPM_INVALID_VERSION_UNION (a || member that isn't valid semver) and ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION (a * wildcard combined with a version).
  • AllowBuildPolicy switched to two HashSet<String> (expanded_allowed, expanded_disallowed) populated through expand_package_version_specs. AllowBuildPolicy::from_config returns Result<Self, VersionPolicyError>; the install pipeline propagates via a new InstallFrozenLockfileError::VersionPolicy variant.
  • check order now mirrors upstream's createAllowBuildFunction: disallowed checked first, allowed second, both against bare name and name@version. This corrected a pre-existing pacquet divergence — the old matcher checked exact-version first, so a bare-name disallow used to lose to an exact-version allow.

Wildcards in allowBuilds keys (is-*, @scope/*) — the original audit incorrectly flagged this as upstream-supported. It's not: upstream's 'should not allow patterns in allowBuilds' test explicitly asserts they don't match real packages because the matcher uses HashSet::contains (literal lookup). Pacquet preserves the same behavior. createPackageVersionPolicy (which DOES support wildcards via Matcher) is a separate upstream function used by minimumReleaseAgeExclude / dlx — pacquet doesn't have those features yet.

Originally:

pacquet reads pnpm.allowBuilds and pnpm.dangerouslyAllowAllBuilds from the project's package.json only.

Upstream sources both from the Config object (.npmrc, workspace YAML, env). … Upstream also runs allowBuilds keys through expandPackageVersionSpecs.

6. Optional-dep build failures hard-fail ✅ Addressed in 98c9886

PR #419 ported building/during-install/src/index.ts:218-240. SnapshotEntry now surfaces the snapshot-level optional flag (previously dropped by the lockfile reader); BuildModules consults it after run_postinstall_hooks returns, swallows the failure when optional == true, and emits a pnpm:skipped-optional-dependency event (new LogEvent::SkippedOptionalDependency variant in pacquet-reporter, payload { details, package: { id, name, version }, prefix, reason: BuildFailure }). Non-optional failures still propagate as BuildModulesError::LifecycleScript. Lifecycle Script/Exit events also now carry the optional flag through to the wire.

SkippedOptionalReason declares all four upstream reasons (BuildFailure, UnsupportedEngine, UnsupportedPlatform, ResolutionFailure), but only BuildFailure has an emit site today. ResolutionFailure upstream carries a distinct package shape (bareSpecifier, no id) and will need a sibling struct when an emit site lands.

Test parity: ported the upstream 'do not fail on an optional dependency that has a non-optional dependency with a failing postinstall script' case from installing/deps-installer/test/install/optionalDependencies.ts:563 as an integration test driving Install::run end-to-end through the live mock registry; the BuildModules unit tests exercise the same fixture identity (@pnpm.e2e/failing-postinstall@1.0.0) and exact script body. Required upgrading @pnpm/registry-mock from ^3.16.0 to ^6.0.0 (the failing-postinstall fixture was added in v5.x) and introducing a Node wrapper (tasks/registry-mock/launch.mjs) that drives the package's programmatic API with useNodeVersion: '20.16.0' — the bundled verdaccio 5.33 rejects its 64-char storage secret on Node 22+ otherwise.

Originally:

When a build fails for a package marked optional, upstream emits skippedOptionalDependencyLogger and swallows the error. pacquet propagates every error.

Impact: installs that should succeed (optional native dep failing to compile on the current platform) hard-fail.

7. Malformed package.json is fatal ❌ False alarm

The original audit claimed safeReadPackageJsonFromDir returns null on missing OR malformed package.json. It does not. In v11 (pkg-manifest/reader/src/index.ts:40-46) safeReadPackageJson only swallows ENOENT; malformed JSON surfaces as BAD_PACKAGE_JSON and propagates. pacquet's behavior already matches: Ok(None) on NotFound, hard error otherwise (package-manifest/src/lib.rs:244-250). No change required.

8. pnpm:lifecycle NDJSON events not emitted ✅ Addressed in 653bc3d

run_lifecycle_hook now emits pnpm:lifecycle events through the Reporter capability with the three upstream message shapes (Script before spawn, Stdio per output line, Exit after wait). Stdio piping is wired alongside (was item #11). pnpm:ignored-scripts also lands in the same commit, emitted once after BuildModules::run returns.

Originally:

pacquet uses tracing::debug! for lifecycle logs. Upstream emits lifecycleLogger.debug events and pipes stdout/stderr through byline-buffered logs. @pnpm/cli.default-reporter consumes those events as the pnpm:lifecycle channel — pacquet's NDJSON output is therefore parsed as an empty channel by the upstream reporter.

9. patchedDependencies not applied ✅ Addressed in 6af026f + cba5422

PR #425 (slices A + B) landed the foundation:

  • New pacquet-patching crate porting @pnpm/patching.types + @pnpm/patching.config + calcPatchHashes. Types (PatchInfo, ExtendedPatchInfo, PatchGroupRangeItem, PatchGroup, PatchGroupRecord), key parser (parse_key), grouping (group_patched_dependencies with ERR_PNPM_PATCH_NON_SEMVER_RANGE), matcher (get_patch_info with ERR_PNPM_PATCH_KEY_CONFLICT), verify (all_patch_keys + verify_patches with ERR_PNPM_UNUSED_PATCH), SHA-256 hex with CRLF→LF normalization (create_hex_hash_from_file), and the workspace-dir-anchored resolve_and_group helper.
  • Config plumbing: WorkspaceSettings.patched_dependencies deserializes patchedDependencies from pnpm-workspace.yaml (pnpm v11 stopped reading these from package.json#pnpm); Config.workspace_dir + Config.patched_dependencies carry the raw map; Config::resolved_patched_dependencies() runs resolve_and_group on demand. IndexMap is used end-to-end so YAML insertion order survives into PatchGroup.range, matching upstream's JS-object iteration order in PATCH_KEY_CONFLICT diagnostics.
  • Install-pipeline threading: InstallFrozenLockfile::run calls config.resolved_patched_dependencies() once per install and builds a HashMap<PackageKey, ExtendedPatchInfo> keyed by peer-stripped key via get_patch_info. PatchKeyConflictError propagates as a new InstallFrozenLockfileError::PatchKeyConflict variant.
  • Cache-key wiring: BuildModules gains a patches field. calc_dep_state's patch_file_hash is fed from patch.map(|p| p.hash.as_str()), matching upstream's patchFileHash: depNode.patch?.hash at during-install/src/index.ts:201.

PR #427 (slice C) landed the patch applier and build-trigger wiring:

  • New pacquet_patching::apply_patch_to_dir using diffy (MIT OR Apache-2.0, pure Rust, no subprocess). Supports Modify, Create, Delete operations. Surfaces ERR_PNPM_PATCH_NOT_FOUND, ERR_PNPM_INVALID_PATCH, ERR_PNPM_PATCH_FAILED. Upstream's comment explicitly rules out patch (Windows) and git apply (subdir-of-repo); pacquet sidesteps the same way with an in-process applier. Rename/Copy operations fall through to ERR_PNPM_PATCH_FAILED with a descriptive message — they don't appear in patch-package-style patches in practice.
  • build_sequence::get_subgraph_to_build includes patch.is_some() in the walk (mirrors upstream's node.requiresBuild || node.patch != null at buildSequence.ts:47,60). BuildModules::run's inner per-snapshot guard becomes requires_build || patch.is_some().
  • allowBuilds policy now only gates scripts (not patches): disallowed/ignored sets should_run_scripts = false and the patch still applies, matching upstream's if (node.requiresBuild) switch with ignoreScripts = true at during-install/src/index.ts:88-101.
  • Patch application fires before run_postinstall_hooks (mirrors during-install/src/index.ts:171-178). New BuildModulesError::PatchFilePathMissing surfaces ERR_PNPM_PATCH_FILE_PATH_MISSING when a resolved patch has a hash but no path.
  • include_dep_graph_hash is now should_run_scripts (was unconditionally true), matching includeDepGraphHash: hasSideEffects at line 202. Cache-write gate becomes (is_patched || has_side_effects) && side_effects_cache_write.
  • Security hardening (review-flagged): resolve_target helper rejects absolute paths and any path with ParentDir, RootDir, or Prefix components so a malicious patch can't escape patched_dir. Create refuses to overwrite an existing file. Delete validates hunks via diffy::apply before unlinking — stale or wrong-target patches surface as ERR_PNPM_PATCH_FAILED instead of silently deleting.
  • File reads use String::from_utf8_lossy to match Node fs.readFile(..., 'utf8') parity (matches upstream's vendored patch-package behavior).

Out of scope (deferred):

  • Patch-file hash recompute on the lockfile-driven path is fed from Config::resolved_patched_dependencies only; the lockfile's top-level patchedDependencies: { key: hash } map (used by ignorePackageManifest installs upstream) isn't surfaced yet.
  • E2E parity test with upstream's simple-with-patch fixture through the live mock registry — unit-level coverage of the same flow is in place via patch_only_snapshot_gets_patched_via_build_modules and the apply-crate test suite.

Originally:

The build trigger upstream is requiresBuild || patch != null. pacquet only checks requires_build (TODO already in the code).

Patch application itself is also missing.

10. No isBuilt skip / side-effects cache ✅ Addressed in 9c340a7 + 97d7439 + 55f6f76

PR #422 landed the foundation:

  • New pacquet-graph-hasher crate porting hashObject (byte-for-byte parity with object-hash@3.0.0), calcDepState + calcDepGraphHash, and ENGINE_NAME. Pinning test: hashObject({b:1,a:2}) == "48AVoXIXcTKcnHt8qVKp5vNw4gyOB5VfztHwtYBRcAQ=", matching upstream's published fixture.
  • VerifyResult.side_effects_maps in pacquet-store-dir: the verify path no longer drops PackageFilesIndex.side_effects after extraction. The added/deleted overlay is applied per cache key and surfaced as HashMap<cache_key, FilesMap>, mirroring upstream's applySideEffectsDiffWithMaps. Malformed digests now fail-closed (drop the whole cache_key entry rather than producing a partial overlay).
  • Config.side_effects_cache config knob (default true), wired from pnpm-workspace.yaml's sideEffectsCache field through WorkspaceSettings::apply_to.

PR #423 then wired the READ-path gate end-to-end:

  • pacquet_tarball::PrefetchResult.side_effects_maps surfaces the per-row overlay the prefetch loop previously dropped.
  • CreateVirtualStoreOutput.side_effects_maps_by_snapshot threads it to the build site, keyed by PackageKey. Peer-variants share one Arc<_> because the store-index row is peer-stripped.
  • New pacquet_package_manager::deps_graph::build_deps_graph adapter walks snapshots + packages into a DepsGraph<PackageKey> for the hasher; full_pkg_id matches upstream's createFullPkgId.
  • pacquet_graph_hasher::detect_node_major() discovers the host Node major once per install (on a spawn_blocking worker); the result feeds engine_name. No node on PATH → gate falls through to "rebuild".
  • BuildModules computes calc_dep_state per requires_build snapshot, looks the cache key up in the threaded side_effects_maps_by_snapshot, and continues on hit (mirrors upstream's !node.isBuilt filter). Config.side_effects_cache: false short-circuits the lookup; the dep-graph is built lazily so the disabled path doesn't pay the O(|snapshots|) cost.
  • Tests: using_side_effects_cache_skips_rebuild ports upstream's 'using side effects cache'; negative pair side_effects_cache_disabled_bypasses_the_gate; plus deps_graph::tests covering full_pkg_id shape, deps + optionalDeps fold, and missing-metadata skip.
  • Hot-cache benchmark: pacquet install ran ~10% faster than main at merge; cold-cache within noise; pacquet still 2.55× / 3.89× faster than pnpm (cold / hot).

PR #424 then wired the WRITE path:

  • pacquet_store_dir::add_files_from_dir walks the post-build package directory and re-CAFSes each file; symlinks read/recurse via the resolved canonical path (TOCTOU-safe), top-level node_modules skipped, cycle-safe via recursion-stack visited set.
  • pacquet_store_dir::upload queues a WriteMsg::SideEffectsUpload to the writer task; the actual R/M/W (load existing row → algo == HASH_ALGORITHM check → calculate_diff → insert into side_effects map) runs inside the writer's transaction so concurrent uploads to the same row stay commutative. Bails silently on missing base row / algo mismatch, matching upstream's if (!existingFilesIndex) return and ALGO_MISMATCH.
  • StoreIndexWriter now drives a WriteMsg enum (Replace vs SideEffectsUpload) and coalesces per-key updates inside each batch. Hoisted up to InstallFrozenLockfile::run so the writer spans both phases.
  • Config.side_effects_cache_readonly: bool (default false) — mirrors pnpm's sideEffectsCacheReadonly; helpers Config::side_effects_cache_read() / side_effects_cache_write() consolidate the gate precedence.
  • BuildModules WRITE call site fires after run_postinstall_hooks returns Ok and the gate is open, swallowing upload errors with tracing::warn! to match upstream's try { upload } catch { logger.warn } at building/during-install/src/index.ts:208-215.
  • build_deps_subgraph bounds the cache-hashing graph walk to the forward closure of requires_build snapshots so pure-JS installs skip the walk entirely. Upstream's lockfileToDepGraph builds the full graph because its consumers extend beyond cache hashing; pacquet's graph is consumed only by calc_dep_state today, so the closure-bounded walk produces byte-identical cache keys with strictly less work.
  • encode_package_files_index iterates files, side_effects, and SideEffectsDiff.added in sorted-key order so the on-disk row payload is byte-stable across pacquet writes.
  • Tests port the upstream WRITE-path cases: write_path_populates_side_effects_row (mirrors 'a postinstall script does not modify the original sources added to the store'), write_path_disabled_skips_upload (gate off counterpart), upload_error_does_not_interrupt_install (mirrors 'uploading errors do not interrupt installation', force add_files_from_dir to fail via a 0-permission file in the package dir).
  • Benchmark at merge: cold install on par with main; hot cache ~7% faster than main (690ms vs 738ms); pacquet still 2.5× / 3.5× faster than pnpm (cold / hot).

Out of scope (separate items):

  • Patch-file hash in cache key — depends on patchedDependencies (build: add packages to release to npm #9 below).
  • requires_build recompute on the R/M/W path. Upstream recomputes from (manifest, filesMap) when the existing row's field is None; pacquet leaves it as-is since cold-batch downloads already populate it with a real value.

Originally:

pacquet rebuilds on every run. Upstream skips when node.isBuilt is set (gate at during-install/src/index.ts:73-77, flag declared at buildSequence.ts:19) and uploads the post-build state to the side-effects cache (during-install/src/index.ts:198-216). Relevant for warm-install perf.

11. Stdio is Stdio::inherit() ✅ Addressed in 653bc3d

run_lifecycle_hook now spawns each script with Stdio::piped() and pumps each stream on its own thread, emitting one pnpm:lifecycle Stdio event per line. Bundled with the #8 fix.

12. No build concurrency ✅ Addressed in bb38640

PR #429 added Config.child_concurrency: u32 with the upstream-matching default min(4, availableParallelism()) and the negative-offset semantics from getWorkspaceConcurrency (n < 0max(1, parallelism - |n|)). WorkspaceSettings::apply_to reads childConcurrency out of pnpm-workspace.yaml and feeds it through resolve_child_concurrency.

BuildModules::run now dispatches each chunk's members through par_iter().try_for_each on a per-install rayon ThreadPoolBuilder::new().num_threads(child_concurrency.max(1)) pool. Chunks themselves remain sequential to preserve the topological order produced by build_sequence; only members within a chunk run concurrently — matching upstream's runGroups(getWorkspaceConcurrency(opts.childConcurrency), groups) at during-install/src/index.ts:124.

The per-snapshot work was extracted into a free build_one_snapshot function so the rayon dispatch can call it once per chunk member. ignored_builds (the dedup set surfaced as pnpm:ignored-scripts) and deps_state_cache (the recursive memo used by calc_dep_state) are wrapped in Mutex so they survive concurrent chunk-member access. The memo is shared across all chunks so diamond-shaped subgraphs still hit it.

Originally:

buildSequence chunks are independent within a chunk and upstream runs each chunk under runGroups(getWorkspaceConcurrency(opts.childConcurrency), ...). pacquet runs chunks sequentially and members within a chunk sequentially. Correctness-safe, perf-only.

Minor

13. npx only-allow pnpm skip is install-stage-only ❌ False alarm

The original audit said pacquet skips this script only for the install stage. It actually skips for all three install stages (lifecycle.rs:96, :110, :117). Upstream's check at runLifecycleHook.ts:98-100 is stage-agnostic and fires from every entry point — pnpm run, pnpm exec, pnpm publish, pnpm version, prepare-package — but pacquet doesn't have any of those entry points yet. Once they land they should mirror the same guard.

14. No unsafe-perm / uid-gid drop ✅ Addressed in bb38640 (with deferred auto-detect)

PR #429 added Config.unsafe_perm: bool (default true) and threads it through BuildModulesRunPostinstallHooks. When false, pacquet_executor's spawn path sets TMPDIR=node_modules/.tmp for the child process — matching the half of upstream's behavior that actually does work. The uid/gid drop side of upstream is a no-op in practice anyway: pnpm's @pnpm/npm-lifecycle only assigns child.uid = opts.uid / child.gid = opts.gid when both opts.user and opts.group are populated, and pnpm never populates them — so the upstream setuid(opts.uid) call just re-applies the current process's uid/gid.

WorkspaceSettings::apply_to reads unsafePerm out of pnpm-workspace.yaml. On Windows, apply_to forces unsafe_perm = true regardless of what yaml says, matching upstream's process.platform === 'win32' gate (running lifecycle scripts under a uid/gid drop is POSIX-only).

Deferred — auto-root-detect: upstream's extendBuildOptions.ts:83-86 auto-flips unsafePerm = false on POSIX when getuid() === 0 && setgid is available, which requires libc (not currently in [workspace.dependencies]). For now, root-run CI must set unsafePerm: false in yaml explicitly. Tracked as a follow-up — needs a workspace-dep approval.

Originally:

Upstream drops privileges to opts.user / opts.group when !unsafePerm and not Windows. pacquet ignores. Matters for root-run CI.

15. No scriptsPrependNodePath ✅ Addressed in bb38640

PR #429 added a tri-state ScriptsPrependNodePath enum to pacquet-config (Always / Never / WarnOnly) with a custom serde Deserialize for the upstream boolean | "warn-only" yaml shape (matches Config.scriptsPrependNodePath). Config.scripts_prepend_node_path defaults to Never, matching upstream's StrictBuildOptions.scriptsPrependNodePath: false.

The config enum mirrors pacquet_executor::ScriptsPrependNodePath (which already existed to drive extend_path). The mirror keeps serde wiring in the config crate and out of the executor; conversion happens at the BuildModules call site in install_frozen_lockfile.rs. BuildModules threads the value into each RunPostinstallHooks spawn so it reaches extend_path and produces upstream's PATH-extension behavior.

Originally:

extendPath prepends dirname(node) when scriptsPrependNodePath is set, handling true / false / 'warn-only' / null. pacquet does not.

16. No getSubgraphToBuild filter trimming ❌ False alarm

Re-checked during the #429 slice. Pacquet's get_subgraph_to_build already trims: each child is walked, its own gate child_should_be_built || needs_build || has_patch is OR'd into the parent's requires_build propagation, and only nodes whose subtree has a buildable (or patched) descendant survive into the returned subgraph. Mirrors upstream's buildSequence.ts:29-67 walk faithfully — the original audit missed that this trimming was already in place when PR #391 landed. No code change needed.


All 16 items from the original audit are resolved. Critical items (#1, #2, #3, #4) make every real-world postinstall work — a dep's postinstall calling node-gyp finds the binary (#2 — ancestor .bin walk), reads npm_lifecycle_event / npm_package_* (#1 — env stamping), runs under the right shell on each platform (#4), and finds its sibling-dep bins (#3LinkVirtualStoreBins). Moderate items (#5, #6, #8, #9, #10, #11, #12) land config-source parity, optional-dep tolerance, lifecycle NDJSON, patchedDependencies, side-effects cache, stdio piping, and build concurrency. Minor items (#14 with the deferred POSIX root auto-detect noted in §14, #15) and the two false-alarm rechecks (#7, #13, #16) close out the audit. Pacquet's lifecycle-script subsystem is now at full upstream-pnpm-v11 parity for the gaps this audit identified.

Cross-references used during the audit:


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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions