Audit of pacquet's dependency-build / lifecycle-script subsystem against pnpm v11 (commit 80037699fb). 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. The items below are the remaining divergences, ranked by how badly they break real installs.
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
pacquet sets only PATH, INIT_CWD, PNPM_SCRIPT_SRC_DIR (lifecycle.rs:149-151).
Upstream goes through @pnpm/npm-lifecycle's lifecycle() → makeEnv (npm-lifecycle/index.js:74-92, :354-414) and sets at minimum:
npm_lifecycle_event (the stage)
npm_lifecycle_script (the resolved script)
npm_node_execpath / NODE (process.execPath)
npm_package_json (path to package.json)
npm_execpath
npm_config_node_gyp (bundled node-gyp path)
npm_package_* for name, version, every key in config, every key in engines, every key in bin
npm_config_user_agent (set in exec/lifecycle/src/runLifecycleHook.ts:123)
TMPDIR under node_modules/.tmp when !unsafePerm (npm-lifecycle/index.js:96-104)
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
pacquet only prepends pkg_root/node_modules/.bin plus extra_bin_paths (lifecycle.rs:189-201).
Upstream extendPath (npm-lifecycle/lib/extendPath.js:5-27) 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 rarely exists. The bins it actually needs (node-gyp, node-pre-gyp, sibling-dep CLIs) live higher up. Without the ancestor walk, most native postinstalls fail to resolve their tools.
3. linkBinsOfDependencies not run before lifecycle scripts
Upstream relinks a dep's own node_modules/.bin from its children right before running its scripts (building/during-install/src/index.ts:168). That's what populates pkg_root/node_modules/.bin in the first place.
pacquet skips this step. Combined with #2 above, anything that shells out to a sibling-dep CLI cannot find the binary.
4. Hardcoded sh -c on Windows
lifecycle.rs:145 hardcodes sh -c.
Upstream picks the shell based on platform and config (npm-lifecycle/index.js:241-252): 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
pacquet reads pnpm.allowBuilds and pnpm.dangerouslyAllowAllBuilds from the project's package.json only (build_modules.rs:39-85).
Upstream sources both from the Config object (.npmrc, workspace YAML, env) — see config/reader/src/dependencyBuildOptions.ts.
Upstream also runs allowBuilds keys through expandPackageVersionSpecs (config/version-policy/src/index.ts:15-90), which supports name patterns (e.g. @scope/*) and version unions like foo@1.0.0||2.0.0. pacquet only does literal name@version and bare name lookups.
6. Optional-dep build failures hard-fail
When a build fails for a package marked optional, upstream emits skippedOptionalDependencyLogger and swallows the error (building/during-install/src/index.ts:226-239). 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
pacquet returns Ok(false) on NotFound but treats malformed JSON as a hard error (lifecycle.rs:89-91).
Upstream safeReadPackageJsonFromDir returns null on missing OR malformed package.json (exec/lifecycle/src/index.ts:22-23) and runPostinstallHooks returns false.
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
The build trigger upstream is requiresBuild ?? patch != null (during-install/src/index.ts:74-77). pacquet only checks requires_build (TODO already in the code).
Patch application itself (during-install/src/index.ts:170-178) is also missing.
10. No isBuilt skip / side-effects cache
pacquet rebuilds on every run. Upstream skips when node.isBuilt is set (during-install/src/index.ts) 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
buildSequence chunks are independent within a chunk and upstream runs each chunk under runGroups(getWorkspaceConcurrency(opts.childConcurrency), ...) (during-install/src/index.ts:124). 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
lifecycle.rs:111-113 skips this script string only for the install stage. Upstream skips it for every stage (runLifecycleHook.ts:100). A postinstall: \"npx only-allow pnpm\" in a dep would currently execute under pacquet.
14. No unsafe-perm / uid-gid drop
Upstream drops privileges to opts.user / opts.group when !unsafePerm and not Windows (npm-lifecycle/index.js:204-220). pacquet ignores. Matters for root-run CI.
15. No scriptsPrependNodePath
extendPath prepends dirname(node) when scriptsPrependNodePath is set (extendPath.js:20-23). pacquet does not.
16. No getSubgraphToBuild filter trimming
pacquet's build_sequence (PR #391) follows the upstream walk faithfully, but does not trim the subgraph the way upstream does once it reaches a node whose subtree has no buildable descendant. The current behavior is correct (extra nodes are no-op'd inside the loop), just slightly more work.
Most of the critical items are interlocking: #1, #2, and #3 together explain why a dep's postinstall calling node-gyp cannot find the binary, can't read npm_lifecycle_event, and runs in the wrong working environment. Fixing them as a single chunk likely unblocks the bulk of real-world installs.
Cross-references used during the audit:
Written by an agent (Claude Code, claude-opus-4-7).
Audit of pacquet's dependency-build / lifecycle-script subsystem against pnpm v11 (commit
80037699fb). 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. The items below are the remaining divergences, ranked by how badly they break real installs.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
pacquet sets only
PATH,INIT_CWD,PNPM_SCRIPT_SRC_DIR(lifecycle.rs:149-151).Upstream goes through
@pnpm/npm-lifecycle'slifecycle()→makeEnv(npm-lifecycle/index.js:74-92,:354-414) and sets at minimum:npm_lifecycle_event(the stage)npm_lifecycle_script(the resolved script)npm_node_execpath/NODE(process.execPath)npm_package_json(path to package.json)npm_execpathnpm_config_node_gyp(bundled node-gyp path)npm_package_*forname,version, every key inconfig, every key inengines, every key inbinnpm_config_user_agent(set inexec/lifecycle/src/runLifecycleHook.ts:123)TMPDIRundernode_modules/.tmpwhen!unsafePerm(npm-lifecycle/index.js:96-104)Impact: native build deps (
sharp,node-sass,node-pre-gyp,bcrypt, anything callingnode-gyp) read these and fail without them. Common postinstalls also readnpm_package_version/npm_package_config_*.Related: pacquet's
Command::new(\"sh\")inherits the parent env unfiltered, so leftovernpm_*vars from any wrapping invocation leak through. UpstreammakeEnvstrips them.2. PATH does not walk ancestor
node_modules/.binpacquet only prepends
pkg_root/node_modules/.binplusextra_bin_paths(lifecycle.rs:189-201).Upstream
extendPath(npm-lifecycle/lib/extendPath.js:5-27) walks every ancestornode_modules/.binsegment fromwdupward, plus a bundlednode-gyp-bin, plusextraBinPaths, plus optionally the dir ofprocess.execPathwhenscriptsPrependNodePathis set.Impact: inside the virtual store, a dep's own
pkg_root/node_modules/.binrarely exists. The bins it actually needs (node-gyp,node-pre-gyp, sibling-dep CLIs) live higher up. Without the ancestor walk, most native postinstalls fail to resolve their tools.3.
linkBinsOfDependenciesnot run before lifecycle scriptsUpstream relinks a dep's own
node_modules/.binfrom its children right before running its scripts (building/during-install/src/index.ts:168). That's what populatespkg_root/node_modules/.binin the first place.pacquet skips this step. Combined with #2 above, anything that shells out to a sibling-dep CLI cannot find the binary.
4. Hardcoded
sh -con Windowslifecycle.rs:145hardcodessh -c.Upstream picks the shell based on platform and config (
npm-lifecycle/index.js:241-252):cmd /d /s /con Windows,@yarnpkg/shellwhenshellEmulatoris set, or a customscriptShell.Impact: scripts cannot run at all on Windows.
scriptShellandshellEmulatorconfig are silently ignored on every platform.Moderate — less common cases or specific configs
5.
allowBuildsconfig source and spec parserpacquet reads
pnpm.allowBuildsandpnpm.dangerouslyAllowAllBuildsfrom the project'spackage.jsononly (build_modules.rs:39-85).Upstream sources both from the
Configobject (.npmrc, workspace YAML, env) — seeconfig/reader/src/dependencyBuildOptions.ts.Upstream also runs
allowBuildskeys throughexpandPackageVersionSpecs(config/version-policy/src/index.ts:15-90), which supports name patterns (e.g.@scope/*) and version unions likefoo@1.0.0||2.0.0. pacquet only does literalname@versionand barenamelookups.6. Optional-dep build failures hard-fail
When a build fails for a package marked optional, upstream emits
skippedOptionalDependencyLoggerand swallows the error (building/during-install/src/index.ts:226-239). pacquet propagates every error.Impact: installs that should succeed (optional native dep failing to compile on the current platform) hard-fail.
7. Malformed
package.jsonis fatalpacquet returns
Ok(false)onNotFoundbut treats malformed JSON as a hard error (lifecycle.rs:89-91).Upstream
safeReadPackageJsonFromDirreturnsnullon missing OR malformed package.json (exec/lifecycle/src/index.ts:22-23) andrunPostinstallHooksreturnsfalse.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 appliedThe build trigger upstream is
requiresBuild ?? patch != null(during-install/src/index.ts:74-77). pacquet only checksrequires_build(TODO already in the code).Patch application itself (
during-install/src/index.ts:170-178) is also missing.10. No
isBuiltskip / side-effects cachepacquet rebuilds on every run. Upstream skips when
node.isBuiltis set (during-install/src/index.ts) 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 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
buildSequencechunks are independent within a chunk and upstream runs each chunk underrunGroups(getWorkspaceConcurrency(opts.childConcurrency), ...)(during-install/src/index.ts:124). pacquet runs chunks sequentially and members within a chunk sequentially. Correctness-safe, perf-only.Minor
13.
npx only-allow pnpmskip is install-stage-onlylifecycle.rs:111-113skips this script string only for theinstallstage. Upstream skips it for every stage (runLifecycleHook.ts:100). Apostinstall: \"npx only-allow pnpm\"in a dep would currently execute under pacquet.14. No
unsafe-perm/ uid-gid dropUpstream drops privileges to
opts.user/opts.groupwhen!unsafePermand not Windows (npm-lifecycle/index.js:204-220). pacquet ignores. Matters for root-run CI.15. No
scriptsPrependNodePathextendPathprependsdirname(node)whenscriptsPrependNodePathis set (extendPath.js:20-23). pacquet does not.16. No
getSubgraphToBuildfilter trimmingpacquet's
build_sequence(PR #391) follows the upstream walk faithfully, but does not trim the subgraph the way upstream does once it reaches a node whose subtree has no buildable descendant. The current behavior is correct (extra nodes are no-op'd inside the loop), just slightly more work.Most of the critical items are interlocking: #1, #2, and #3 together explain why a dep's postinstall calling
node-gypcannot find the binary, can't readnpm_lifecycle_event, and runs in the wrong working environment. Fixing them as a single chunk likely unblocks the bulk of real-world installs.Cross-references used during the audit:
pnpm/pnpmv11 at commit80037699fb— https://github.com/pnpm/pnpm/tree/80037699fbnpm/npm-lifecycle(@pnpm/npm-lifecyclefork)Written by an agent (Claude Code, claude-opus-4-7).