You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on May 14, 2026. It is now read-only.
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.
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 / .cmdscript_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.
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
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).
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.
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).
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.
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.
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).
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.
PR #429 added Config.child_concurrency: u32 with the upstream-matching default min(4, availableParallelism()) and the negative-offset semantics from getWorkspaceConcurrency (n < 0 → max(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 BuildModules → RunPostinstallHooks. 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 (#3 — LinkVirtualStoreBins). 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.
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 viabuildSequence/graphSequencer. PR #333 addedLinkVirtualStoreBins, which populates each virtual-store slot'snode_modules/.binbeforeBuildModulesruns (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 atpnpm/npm-lifecycle@d2d8e790). Several items below cite that external repo rather thanpnpm/pnpm.The relevant pacquet code lives in:
crates/package-manager/src/build_modules.rscrates/package-manager/src/build_sequence.rscrates/executor/src/lifecycle.rsCritical — common installs will fail
1. Lifecycle env vars almost entirely missing ✅ Addressed in ff6edea
PR #418 added a
make_envmodule portingmakeEnvand the surrounding env block from@pnpm/npm-lifecycle@d2d8e790(index.js:73-104+:354-414) plus the pnpm wrapper'sextraEnvadditions (runLifecycleHook.ts:119-124). Lifecycle scripts now seenpm_lifecycle_event,npm_lifecycle_script,npm_node_execpath/NODE,npm_package_json,npm_execpath,npm_config_node_gyp,npm_package_*(name,version, recursiveconfig/engines/bin),npm_config_user_agent,INIT_CWD,PNPM_SCRIPT_SRC_DIR, andTMPDIRunder<wd>/node_modules/.tmpwhenunsafe_permis false. The spawn now strips inherited env (env_clear()) so leftovernpm_*keys from a wrapping invocation cannot leak through.Originally:
2. PATH does not walk ancestor
node_modules/.bin✅ Addressed in ff6edeaPR #418 added an
extend_pathmodule portingextendPathfrom@pnpm/npm-lifecycle@d2d8e790(lib/extendPath.js:5-27). For a dep at<root>/node_modules/.pnpm/<slot>/node_modules/<pkg>,PATHnow contains the dep's own.bin, the.pnpmslot's.bin, the project root's.bin, the (currently unbundled)node-gyp-binslot,extra_bin_paths, and finally the inherited PATH. The tri-statescriptsPrependNodePathenum is in place but config-plumbing for it is its own item — see #15.Originally:
4. Hardcoded
sh -con Windows ✅ Addressed in ff6edeaPR #418 added a
shellmodule porting the shell-selection block from@pnpm/npm-lifecycle@d2d8e790(index.js:241-252) plus the pnpm-side.bat/.cmdguard (runLifecycleHook.ts:63-71). Resolution order: customscript_shellwins, otherwisecmd /d /s /c(with%ComSpec%lookup) on Windows, otherwisesh -c. A Windows-side.bat/.cmdscript_shellnow surfaces asERR_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 bySelectedShell.windows_verbatim_argsbut not yet applied to the spawnedCommand.@yarnpkg/shell/shellEmulator: truehas no clean Rust port; pacquet ignores the flag.Originally:
Moderate — less common cases or specific configs
5.
allowBuildsconfig source and spec parser ✅ Addressed in 6af026f + eefff34PR #425 moved
AllowBuildPolicyoffpackage.jsonontopnpm-workspace.yaml.Confignow carriesallow_builds: HashMap<String, bool>anddangerously_allow_all_builds: bool, populated byWorkspaceSettings::apply_tofrom the matching yaml keys. The oldAllowBuildPolicy::from_manifestis gone; the policy is constructed viaAllowBuildPolicy::from_config(&Config)at the install entry point.PR #428 then landed the spec parser:
pacquet_package_manager::version_policymodule porting upstream'sexpandPackageVersionSpecs. 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'svalid()). Two upstream error codes propagate:ERR_PNPM_INVALID_VERSION_UNION(a||member that isn't valid semver) andERR_PNPM_NAME_PATTERN_IN_VERSION_UNION(a*wildcard combined with a version).AllowBuildPolicyswitched to twoHashSet<String>(expanded_allowed,expanded_disallowed) populated throughexpand_package_version_specs.AllowBuildPolicy::from_configreturnsResult<Self, VersionPolicyError>; the install pipeline propagates via a newInstallFrozenLockfileError::VersionPolicyvariant.checkorder now mirrors upstream'screateAllowBuildFunction:disallowedchecked first,allowedsecond, both against barenameandname@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
allowBuildskeys (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 usesHashSet::contains(literal lookup). Pacquet preserves the same behavior.createPackageVersionPolicy(which DOES support wildcards viaMatcher) is a separate upstream function used byminimumReleaseAgeExclude/dlx— pacquet doesn't have those features yet.Originally:
6. Optional-dep build failures hard-fail ✅ Addressed in 98c9886
PR #419 ported
building/during-install/src/index.ts:218-240.SnapshotEntrynow surfaces the snapshot-leveloptionalflag (previously dropped by the lockfile reader);BuildModulesconsults it afterrun_postinstall_hooksreturns, swallows the failure whenoptional == true, and emits apnpm:skipped-optional-dependencyevent (newLogEvent::SkippedOptionalDependencyvariant inpacquet-reporter, payload{ details, package: { id, name, version }, prefix, reason: BuildFailure }). Non-optional failures still propagate asBuildModulesError::LifecycleScript. Lifecycle Script/Exit events also now carry theoptionalflag through to the wire.SkippedOptionalReasondeclares all four upstream reasons (BuildFailure,UnsupportedEngine,UnsupportedPlatform,ResolutionFailure), but onlyBuildFailurehas an emit site today.ResolutionFailureupstream carries a distinctpackageshape (bareSpecifier, noid) 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 frominstalling/deps-installer/test/install/optionalDependencies.ts:563as an integration test drivingInstall::runend-to-end through the live mock registry; theBuildModulesunit tests exercise the same fixture identity (@pnpm.e2e/failing-postinstall@1.0.0) and exact script body. Required upgrading@pnpm/registry-mockfrom^3.16.0to^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 withuseNodeVersion: '20.16.0'— the bundled verdaccio 5.33 rejects its 64-char storage secret on Node 22+ otherwise.Originally:
7. Malformed❌ False alarmpackage.jsonis fatalThe original audit claimed
safeReadPackageJsonFromDirreturnsnullon missing OR malformedpackage.json. It does not. In v11 (pkg-manifest/reader/src/index.ts:40-46)safeReadPackageJsononly swallowsENOENT; malformed JSON surfaces asBAD_PACKAGE_JSONand propagates. pacquet's behavior already matches:Ok(None)onNotFound, hard error otherwise (package-manifest/src/lib.rs:244-250). No change required.8.
pnpm:lifecycleNDJSON events not emitted ✅ Addressed in 653bc3drun_lifecycle_hooknow emitspnpm:lifecycleevents through theReportercapability 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-scriptsalso lands in the same commit, emitted once afterBuildModules::runreturns.Originally:
9.
patchedDependenciesnot applied ✅ Addressed in 6af026f + cba5422PR #425 (slices A + B) landed the foundation:
pacquet-patchingcrate porting@pnpm/patching.types+@pnpm/patching.config+calcPatchHashes. Types (PatchInfo,ExtendedPatchInfo,PatchGroupRangeItem,PatchGroup,PatchGroupRecord), key parser (parse_key), grouping (group_patched_dependencieswithERR_PNPM_PATCH_NON_SEMVER_RANGE), matcher (get_patch_infowithERR_PNPM_PATCH_KEY_CONFLICT), verify (all_patch_keys+verify_patcheswithERR_PNPM_UNUSED_PATCH), SHA-256 hex with CRLF→LF normalization (create_hex_hash_from_file), and the workspace-dir-anchoredresolve_and_grouphelper.WorkspaceSettings.patched_dependenciesdeserializespatchedDependenciesfrompnpm-workspace.yaml(pnpm v11 stopped reading these frompackage.json#pnpm);Config.workspace_dir+Config.patched_dependenciescarry the raw map;Config::resolved_patched_dependencies()runsresolve_and_groupon demand.IndexMapis used end-to-end so YAML insertion order survives intoPatchGroup.range, matching upstream's JS-object iteration order inPATCH_KEY_CONFLICTdiagnostics.InstallFrozenLockfile::runcallsconfig.resolved_patched_dependencies()once per install and builds aHashMap<PackageKey, ExtendedPatchInfo>keyed by peer-stripped key viaget_patch_info.PatchKeyConflictErrorpropagates as a newInstallFrozenLockfileError::PatchKeyConflictvariant.BuildModulesgains apatchesfield.calc_dep_state'spatch_file_hashis fed frompatch.map(|p| p.hash.as_str()), matching upstream'spatchFileHash: depNode.patch?.hashatduring-install/src/index.ts:201.PR #427 (slice C) landed the patch applier and build-trigger wiring:
pacquet_patching::apply_patch_to_dirusingdiffy(MIT OR Apache-2.0, pure Rust, no subprocess). SupportsModify,Create,Deleteoperations. SurfacesERR_PNPM_PATCH_NOT_FOUND,ERR_PNPM_INVALID_PATCH,ERR_PNPM_PATCH_FAILED. Upstream's comment explicitly rules outpatch(Windows) andgit apply(subdir-of-repo); pacquet sidesteps the same way with an in-process applier.Rename/Copyoperations fall through toERR_PNPM_PATCH_FAILEDwith a descriptive message — they don't appear inpatch-package-style patches in practice.build_sequence::get_subgraph_to_buildincludespatch.is_some()in the walk (mirrors upstream'snode.requiresBuild || node.patch != nullatbuildSequence.ts:47,60).BuildModules::run's inner per-snapshot guard becomesrequires_build || patch.is_some().allowBuildspolicy now only gates scripts (not patches): disallowed/ignored setsshould_run_scripts = falseand the patch still applies, matching upstream'sif (node.requiresBuild)switch withignoreScripts = trueatduring-install/src/index.ts:88-101.run_postinstall_hooks(mirrorsduring-install/src/index.ts:171-178). NewBuildModulesError::PatchFilePathMissingsurfacesERR_PNPM_PATCH_FILE_PATH_MISSINGwhen a resolved patch has a hash but no path.include_dep_graph_hashis nowshould_run_scripts(was unconditionallytrue), matchingincludeDepGraphHash: hasSideEffectsat line 202. Cache-write gate becomes(is_patched || has_side_effects) && side_effects_cache_write.resolve_targethelper rejects absolute paths and any path withParentDir,RootDir, orPrefixcomponents so a malicious patch can't escapepatched_dir.Createrefuses to overwrite an existing file.Deletevalidates hunks viadiffy::applybefore unlinking — stale or wrong-target patches surface asERR_PNPM_PATCH_FAILEDinstead of silently deleting.String::from_utf8_lossyto match Nodefs.readFile(..., 'utf8')parity (matches upstream's vendoredpatch-packagebehavior).Out of scope (deferred):
Config::resolved_patched_dependenciesonly; the lockfile's top-levelpatchedDependencies: { key: hash }map (used byignorePackageManifestinstalls upstream) isn't surfaced yet.simple-with-patchfixture through the live mock registry — unit-level coverage of the same flow is in place viapatch_only_snapshot_gets_patched_via_build_modulesand the apply-crate test suite.Originally:
10. No
isBuiltskip / side-effects cache ✅ Addressed in 9c340a7 + 97d7439 + 55f6f76PR #422 landed the foundation:
pacquet-graph-hashercrate portinghashObject(byte-for-byte parity withobject-hash@3.0.0),calcDepState+calcDepGraphHash, andENGINE_NAME. Pinning test:hashObject({b:1,a:2}) == "48AVoXIXcTKcnHt8qVKp5vNw4gyOB5VfztHwtYBRcAQ=", matching upstream's published fixture.VerifyResult.side_effects_mapsinpacquet-store-dir: the verify path no longer dropsPackageFilesIndex.side_effectsafter extraction. Theadded/deletedoverlay is applied per cache key and surfaced asHashMap<cache_key, FilesMap>, mirroring upstream'sapplySideEffectsDiffWithMaps. Malformed digests now fail-closed (drop the whole cache_key entry rather than producing a partial overlay).Config.side_effects_cacheconfig knob (defaulttrue), wired frompnpm-workspace.yaml'ssideEffectsCachefield throughWorkspaceSettings::apply_to.PR #423 then wired the READ-path gate end-to-end:
pacquet_tarball::PrefetchResult.side_effects_mapssurfaces the per-row overlay the prefetch loop previously dropped.CreateVirtualStoreOutput.side_effects_maps_by_snapshotthreads it to the build site, keyed byPackageKey. Peer-variants share oneArc<_>because the store-index row is peer-stripped.pacquet_package_manager::deps_graph::build_deps_graphadapter walkssnapshots+packagesinto aDepsGraph<PackageKey>for the hasher;full_pkg_idmatches upstream'screateFullPkgId.pacquet_graph_hasher::detect_node_major()discovers the host Node major once per install (on aspawn_blockingworker); the result feedsengine_name. Nonodeon PATH → gate falls through to "rebuild".BuildModulescomputescalc_dep_stateperrequires_buildsnapshot, looks the cache key up in the threadedside_effects_maps_by_snapshot, andcontinues on hit (mirrors upstream's!node.isBuiltfilter).Config.side_effects_cache: falseshort-circuits the lookup; the dep-graph is built lazily so the disabled path doesn't pay the O(|snapshots|) cost.using_side_effects_cache_skips_rebuildports upstream's'using side effects cache'; negative pairside_effects_cache_disabled_bypasses_the_gate; plusdeps_graph::testscoveringfull_pkg_idshape, deps + optionalDeps fold, and missing-metadata skip.pacquet installran ~10% faster thanmainat 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_dirwalks the post-build package directory and re-CAFSes each file; symlinks read/recurse via the resolved canonical path (TOCTOU-safe), top-levelnode_modulesskipped, cycle-safe via recursion-stackvisitedset.pacquet_store_dir::uploadqueues aWriteMsg::SideEffectsUploadto the writer task; the actual R/M/W (load existing row →algo == HASH_ALGORITHMcheck →calculate_diff→ insert intoside_effectsmap) 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'sif (!existingFilesIndex) returnandALGO_MISMATCH.StoreIndexWriternow drives aWriteMsgenum (ReplacevsSideEffectsUpload) and coalesces per-key updates inside each batch. Hoisted up toInstallFrozenLockfile::runso the writer spans both phases.Config.side_effects_cache_readonly: bool(defaultfalse) — mirrors pnpm'ssideEffectsCacheReadonly; helpersConfig::side_effects_cache_read()/side_effects_cache_write()consolidate the gate precedence.BuildModulesWRITE call site fires afterrun_postinstall_hooksreturnsOkand the gate is open, swallowing upload errors withtracing::warn!to match upstream'stry { upload } catch { logger.warn }atbuilding/during-install/src/index.ts:208-215.build_deps_subgraphbounds the cache-hashing graph walk to the forward closure ofrequires_buildsnapshots so pure-JS installs skip the walk entirely. Upstream'slockfileToDepGraphbuilds the full graph because its consumers extend beyond cache hashing; pacquet's graph is consumed only bycalc_dep_statetoday, so the closure-bounded walk produces byte-identical cache keys with strictly less work.encode_package_files_indexiteratesfiles,side_effects, andSideEffectsDiff.addedin sorted-key order so the on-disk row payload is byte-stable across pacquet writes.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', forceadd_files_from_dirto fail via a 0-permission file in the package dir).main; hot cache ~7% faster thanmain(690ms vs 738ms); pacquet still 2.5× / 3.5× faster than pnpm (cold / hot).Out of scope (separate items):
patchedDependencies(build: add packages to release to npm #9 below).requires_buildrecompute on the R/M/W path. Upstream recomputes from(manifest, filesMap)when the existing row's field isNone; pacquet leaves it as-is since cold-batch downloads already populate it with a real value.Originally:
11. Stdio is
Stdio::inherit()✅ Addressed in 653bc3drun_lifecycle_hooknow spawns each script withStdio::piped()and pumps each stream on its own thread, emitting onepnpm:lifecycleStdio event per line. Bundled with the #8 fix.12. No build concurrency ✅ Addressed in bb38640
PR #429 added
Config.child_concurrency: u32with the upstream-matching defaultmin(4, availableParallelism())and the negative-offset semantics fromgetWorkspaceConcurrency(n < 0→max(1, parallelism - |n|)).WorkspaceSettings::apply_toreadschildConcurrencyout ofpnpm-workspace.yamland feeds it throughresolve_child_concurrency.BuildModules::runnow dispatches each chunk's members throughpar_iter().try_for_eachon a per-install rayonThreadPoolBuilder::new().num_threads(child_concurrency.max(1))pool. Chunks themselves remain sequential to preserve the topological order produced bybuild_sequence; only members within a chunk run concurrently — matching upstream'srunGroups(getWorkspaceConcurrency(opts.childConcurrency), groups)atduring-install/src/index.ts:124.The per-snapshot work was extracted into a free
build_one_snapshotfunction so the rayon dispatch can call it once per chunk member.ignored_builds(the dedup set surfaced aspnpm:ignored-scripts) anddeps_state_cache(the recursive memo used bycalc_dep_state) are wrapped inMutexso they survive concurrent chunk-member access. The memo is shared across all chunks so diamond-shaped subgraphs still hit it.Originally:
Minor
13.❌ False alarmnpx only-allow pnpmskip is install-stage-onlyThe original audit said pacquet skips this script only for the
installstage. It actually skips for all three install stages (lifecycle.rs:96,:110,:117). Upstream's check atrunLifecycleHook.ts:98-100is 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(defaulttrue) and threads it throughBuildModules→RunPostinstallHooks. Whenfalse,pacquet_executor's spawn path setsTMPDIR=node_modules/.tmpfor 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-lifecycleonly assignschild.uid = opts.uid/child.gid = opts.gidwhen bothopts.userandopts.groupare populated, and pnpm never populates them — so the upstreamsetuid(opts.uid)call just re-applies the current process's uid/gid.WorkspaceSettings::apply_toreadsunsafePermout ofpnpm-workspace.yaml. On Windows,apply_toforcesunsafe_perm = trueregardless of what yaml says, matching upstream'sprocess.platform === 'win32'gate (running lifecycle scripts under a uid/gid drop is POSIX-only).Deferred — auto-root-detect: upstream's
extendBuildOptions.ts:83-86auto-flipsunsafePerm = falseon POSIX whengetuid() === 0 && setgidis available, which requireslibc(not currently in[workspace.dependencies]). For now, root-run CI must setunsafePerm: falsein yaml explicitly. Tracked as a follow-up — needs a workspace-dep approval.Originally:
15. No
scriptsPrependNodePath✅ Addressed in bb38640PR #429 added a tri-state
ScriptsPrependNodePathenum topacquet-config(Always/Never/WarnOnly) with a custom serdeDeserializefor the upstreamboolean | "warn-only"yaml shape (matchesConfig.scriptsPrependNodePath).Config.scripts_prepend_node_pathdefaults toNever, matching upstream'sStrictBuildOptions.scriptsPrependNodePath: false.The config enum mirrors
pacquet_executor::ScriptsPrependNodePath(which already existed to driveextend_path). The mirror keeps serde wiring in the config crate and out of the executor; conversion happens at theBuildModulescall site ininstall_frozen_lockfile.rs.BuildModulesthreads the value into eachRunPostinstallHooksspawn so it reachesextend_pathand produces upstream's PATH-extension behavior.Originally:
16. No❌ False alarmgetSubgraphToBuildfilter trimmingRe-checked during the #429 slice. Pacquet's
get_subgraph_to_buildalready trims: each child is walked, its own gatechild_should_be_built || needs_build || has_patchis OR'd into the parent'srequires_buildpropagation, and only nodes whose subtree has a buildable (or patched) descendant survive into the returned subgraph. Mirrors upstream'sbuildSequence.ts:29-67walk 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-gypfinds the binary (#2 — ancestor.binwalk), readsnpm_lifecycle_event/npm_package_*(#1 — env stamping), runs under the right shell on each platform (#4), and finds its sibling-dep bins (#3 —LinkVirtualStoreBins). 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:
pnpm/pnpmv11 at commitb4f8f47ac2— https://github.com/pnpm/pnpm/tree/b4f8f47ac2pnpm/npm-lifecycleat commitd2d8e790(pinned by pnpm v11 as@pnpm/npm-lifecycle@1100.0.0-1) — https://github.com/pnpm/npm-lifecycle/tree/d2d8e790Written by an agent (Claude Code, claude-opus-4-7).