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
Pacquet's frozen-lockfile install writes node_modules/.modules.yaml (#331) with the resolved nodeLinker, hoistPattern, publicHoistPattern, includedDependencies, storeDir, virtualStoreDir, and virtualStoreDirMaxLength. Roadmap (#299) lists .modules.yaml validation as a remaining Tier-4 polish item:
.modules.yaml validation triggering re-install on layout change. Upstream's validateModules forces a full re-import when nodeLinker / hoistPattern / publicHoistPattern / etc. differ between runs. Pacquet writes .modules.yaml (#331) but does not compare on the read side, so layout-affecting setting changes are silently ignored.
Direct follow-up to #445 (hoisting). After that PR landed, the on-disk manifest records hoistPattern + publicHoistPattern + hoistedDependencies, but pacquet never compares them on the next install. As a result, changing pnpm-workspace.yaml's hoistPattern: ['*'] → hoistPattern: ['only-this-pkg'] between two pacquet install --frozen-lockfile runs leaves the old hoist symlinks in place — silent layout drift.
virtualStoreOnly installs are exempted from the hoist-pattern check (upstream writes empty patterns deliberately so the next install knows to redo hoisting).
Today
crates/modules-yaml ships read_modules_manifest and the loader handles legacy shamefullyHoist translation, but no install path calls it. crates/package-manager/src/install.rs only writes — it never reads a pre-existing manifest. So all five drift categories above silently pass through pacquet today.
Layout-affecting settings users may flip between installs and not realize:
nodeLinker — isolated ↔ hoisted (the latter not implemented yet, but the field is persisted).
storeDir — relocating the store between installs.
Dependency groups (included) — pacquet install --prod followed by pacquet install would silently merge prod-only and dev layouts.
Plan
A. Read + per-importer per-axis compare
In Install::run (frozen-lockfile path), call read_modules_manifest::<RealApi>(&config.modules_dir) before the install dispatcher. Ok(None) (no manifest) is the first-install case → no validation.
Port validateModules's five axes as a separate validate_modules function in crates/package-manager/src/. Inputs: the loaded Modules, the current Config, the current IncludedDependencies. Output: typed Result<(), ValidateModulesError> with one variant per axis.
Port checkCompatibility's three axes (layoutVersion, storeDir, virtualStoreDir) — LayoutVersion already errors on deserialize, but the path comparisons must run for the install to fail with a clean diagnostic rather than fall through.
Surface errors with miette codes that match upstream's PUBLIC_HOIST_PATTERN_DIFF / HOIST_PATTERN_DIFF / INCLUDED_DEPS_CONFLICT / VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF / UNEXPECTED_STORE / UNEXPECTED_VIRTUAL_STORE_DIR so the user-facing messages match pnpm.
B. forceNewModules purge path
Wire force: bool from pacquet install --force (today a CLI flag exists but only affects extraction). On force, validate-and-purge instead of validate-and-error.
Port removeContentsOfDir's allowlist (.bin, .modules.yaml, and the virtual-store-dir resolve-to-equal exception) so the purge doesn't clobber adjacent project metadata.
Run the purge per affected importer (rather than the whole node_modules) when the diff is HOIST_PATTERN_DIFF or INCLUDED_DEPS_CONFLICT — upstream's importersToPurge shape.
Skip the user-prompt path; pacquet doesn't have an interactive flow (upstream's enquirer.prompt only fires when stdin is a TTY anyway). Always proceed when --force is on.
C. virtualStoreOnly exemption
Mirror upstream's !modules.virtualStoreOnly gate on the hoist-pattern diffs. Pacquet doesn't implement virtualStoreOnly install yet, but the field is on Modules — guard against future activation without changing this path.
D. Tests
Unit tests for validate_modules covering each diff axis: same/diff hoist pattern, same/diff public hoist pattern, same/diff included groups, same/diff store dir, same/diff virtual store dir, same/diff max-length.
Integration test: install with hoistPattern: ['*'], then re-install with hoistPattern: ['banned-only'] → fails with HOIST_PATTERN_DIFF. Add --force variant → purges + re-installs cleanly.
Integration test: install with default include, then re-install with --prod → fails with INCLUDED_DEPS_CONFLICT.
Port the relevant cases from upstream's installing/deps-installer/test/install/hoist.ts:209 (hoistPattern=* throws exception when executed on node_modules installed w/o the option) and :220 (hoistPattern=undefined throws exception when executed on node_modules installed with hoist-pattern=*). Both are listed in plans/TEST_PORTING.md and currently stubbed as known_failures against partial-install (Partial install with --frozen-lockfile: read+write node_modules/.pnpm/lock.yaml and skip unchanged snapshots #433) — they're actually blocked on this validation, not partial install.
GVS (Add global virtual store support to pacquet install --frozen-lockfile #432) — virtualStoreDir comparison must compare the resolved path, not the field value. Pacquet's split-field design keeps config.virtual_store_dir project-local even with GVS on; the persisted manifest field should match.
Background
Pacquet's frozen-lockfile install writes
node_modules/.modules.yaml(#331) with the resolvednodeLinker,hoistPattern,publicHoistPattern,includedDependencies,storeDir,virtualStoreDir, andvirtualStoreDirMaxLength. Roadmap (#299) lists.modules.yamlvalidation as a remaining Tier-4 polish item:Direct follow-up to #445 (hoisting). After that PR landed, the on-disk manifest records
hoistPattern+publicHoistPattern+hoistedDependencies, but pacquet never compares them on the next install. As a result, changingpnpm-workspace.yaml'shoistPattern: ['*']→hoistPattern: ['only-this-pkg']between twopacquet install --frozen-lockfileruns leaves the old hoist symlinks in place — silent layout drift.What upstream does
Upstream's pipeline at pnpm v11
94240bc046:validateModules.ts—Modules(read from disk) vs the current install options:modules.virtualStoreDirMaxLength !== opts.virtualStoreDirMaxLength→VIRTUAL_STORE_DIR_MAX_LENGTH_DIFFmodules.publicHoistPattern ?? [] != opts.publicHoistPattern ?? []→PUBLIC_HOIST_PATTERN_DIFFopts.currentHoistPattern ?? [] != opts.hoistPattern ?? []→HOIST_PATTERN_DIFFopts.include[depsField] != modules.included[depsField](per dependency field) →INCLUDED_DEPS_CONFLICTopts.forceNewModulesdecides whether to purge the affected importer'snode_modulesand re-install, or to fail.checkCompatibility/index.ts— invoked per importer:modules.layoutVersion !== LAYOUT_VERSION→ModulesBreakingChangeErrormodules.storeDir != opts.storeDir(path.relative-aware) →UnexpectedStoreErrormodules.virtualStoreDir != opts.virtualStoreDir→UnexpectedVirtualStoreDirErrorvirtualStoreOnlyinstalls are exempted from the hoist-pattern check (upstream writes empty patterns deliberately so the next install knows to redo hoisting).Today
crates/modules-yamlshipsread_modules_manifestand the loader handles legacyshamefullyHoisttranslation, but no install path calls it.crates/package-manager/src/install.rsonly writes — it never reads a pre-existing manifest. So all five drift categories above silently pass through pacquet today.Layout-affecting settings users may flip between installs and not realize:
hoistPattern/publicHoistPatternfrompnpm-workspace.yaml— the biggest gap; feat: add hoisting support (hoistPattern + publicHoistPattern) (#435) #445's foundation work now records both but the comparison side is missing.nodeLinker—isolated↔hoisted(the latter not implemented yet, but the field is persisted).storeDir— relocating the store between installs.included) —pacquet install --prodfollowed bypacquet installwould silently merge prod-only and dev layouts.Plan
A. Read + per-importer per-axis compare
Install::run(frozen-lockfile path), callread_modules_manifest::<RealApi>(&config.modules_dir)before the install dispatcher.Ok(None)(no manifest) is the first-install case → no validation.validateModules's five axes as a separatevalidate_modulesfunction incrates/package-manager/src/. Inputs: the loadedModules, the currentConfig, the currentIncludedDependencies. Output: typedResult<(), ValidateModulesError>with one variant per axis.checkCompatibility's three axes (layoutVersion,storeDir,virtualStoreDir) —LayoutVersionalready errors on deserialize, but the path comparisons must run for the install to fail with a clean diagnostic rather than fall through.PUBLIC_HOIST_PATTERN_DIFF/HOIST_PATTERN_DIFF/INCLUDED_DEPS_CONFLICT/VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF/UNEXPECTED_STORE/UNEXPECTED_VIRTUAL_STORE_DIRso the user-facing messages match pnpm.B.
forceNewModulespurge pathforce: boolfrompacquet install --force(today a CLI flag exists but only affects extraction). Onforce, validate-and-purge instead of validate-and-error.removeContentsOfDir's allowlist (.bin,.modules.yaml, and the virtual-store-dir resolve-to-equal exception) so the purge doesn't clobber adjacent project metadata.node_modules) when the diff isHOIST_PATTERN_DIFForINCLUDED_DEPS_CONFLICT— upstream'simportersToPurgeshape.enquirer.promptonly fires when stdin is a TTY anyway). Always proceed when--forceis on.C.
virtualStoreOnlyexemption!modules.virtualStoreOnlygate on the hoist-pattern diffs. Pacquet doesn't implementvirtualStoreOnlyinstall yet, but the field is onModules— guard against future activation without changing this path.D. Tests
validate_modulescovering each diff axis: same/diff hoist pattern, same/diff public hoist pattern, same/diff included groups, same/diff store dir, same/diff virtual store dir, same/diff max-length.hoistPattern: ['*'], then re-install withhoistPattern: ['banned-only']→ fails withHOIST_PATTERN_DIFF. Add--forcevariant → purges + re-installs cleanly.include, then re-install with--prod→ fails withINCLUDED_DEPS_CONFLICT.installing/deps-installer/test/install/hoist.ts:209(hoistPattern=* throws exception when executed on node_modules installed w/o the option) and:220(hoistPattern=undefined throws exception when executed on node_modules installed with hoist-pattern=*). Both are listed inplans/TEST_PORTING.mdand currently stubbed asknown_failuresagainst partial-install (Partial install with--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433) — they're actually blocked on this validation, not partial install.Interactions
--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433) — orthogonal. Partial install decides which snapshots to skip; this issue decides whether the whole layout is even valid for the current config. Both run on every frozen install; validation runs first.pacquet install --frozen-lockfile#432) —virtualStoreDircomparison must compare the resolved path, not the field value. Pacquet's split-field design keepsconfig.virtual_store_dirproject-local even with GVS on; the persisted manifest field should match.pacquet install --frozen-lockfile(#431) #443) —validateModulesruns per importer for theincludedaxis. Pacquet's frozen-lockfile path walks every importer; the validation must do the same.Related
Modulesshape:.modules.yamlwrite and verify #331hoistPatternandpublicHoistPattern#435 / feat: add hoisting support (hoistPattern + publicHoistPattern) (#435) #445Written by an agent (Claude Code, claude-opus-4-7).