feat: --no-optional filter for frozen lockfile install (#434 slice 5)#485
Conversation
Pacquet's CLI already exposes `--no-optional` and threads it as a `DependencyGroup` filter through `Install::dependency_groups`. The flag is honored by `SymlinkDirectDependencies` (which already filters by group) and by the on-disk `.modules.yaml.included` field, but the rest of the install pipeline walks the lockfile's `optional_dependencies` maps unconditionally — so the optional subtree was still extracted, linked, and built even when the user asked it to be skipped. This slice closes that gap by adding the same filter upstream applies at `installing/deps-installer/src/install/link.ts:109-111` to pacquet's centralized skip-set plumbing: - `SkippedSnapshots` gains a third disjoint subset `optional_excluded` alongside `installability` (slice 1) and `fetch_failed` (slice 4). `contains` / `iter` already return the union, so every downstream consumer that filters by skip-set (`CreateVirtualStore`, `SymlinkDirectDependencies`, `BuildModules`, hoist, link_bins) now respects `--no-optional` through the same gate. - `iter_installability` still returns only the persistent subset for `.modules.yaml.skipped` writes — `--no-optional` exclusions are transient (matching upstream's behavior of never adding to `opts.skipped` at the filter site), so a later install without `--no-optional` brings the snapshots back into the install graph. - `InstallFrozenLockfile::run` iterates the lockfile snapshots once after the installability + seed pass and inserts every `snap.optional == true` entry into the new subset when the dispatch list doesn't contain `DependencyGroup::Optional`. The `SnapshotEntry::optional` flag is set by the resolver only when the snapshot is reachable **exclusively** through optional edges, so a snapshot reachable both optionally and non-optionally carries `optional: false` and survives the filter — covers upstream's `optionalDependencies.ts:712` case (`dependency that is both optional and non-optional is installed`). Tests: - `install::tests::frozen_install_no_optional_drops_optional_only_snapshots` uses a discriminating fixture: an `optional: true` snapshot whose metadata row is missing from `packages:`. Slice 4 (fetch-failure swallow) only covers `DownloadTarball`/`GitFetch` so `MissingPackageMetadata` propagates even on optional — meaning install success proves the snapshot was dropped **before** cache-key derivation by the slice 5 filter. Test-the-test: inverting the `!include_optional` guard surfaces `MissingPackageMetadata`. - `install::tests::frozen_install_optional_included_surfaces_missing_metadata` pins the polarity: same fixture, `Optional` in the dispatch list, install must abort with `MissingPackageMetadata`. Out of scope: - `installing only optional deps` — pacquet has no CLI flag for that yet. - `optional dependency has bigger priority than regular dependency` — resolver-side priority rule. - Current-lockfile pruning when the include set changes between installs — umbrella slice 6. Closes #480.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (4)
📜 Recent review details⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
🧰 Additional context used📓 Path-based instructions (1)**/*.rs📄 CodeRabbit inference engine (AGENTS.md)
Files:
🧠 Learnings (4)📚 Learning: 2026-05-07T23:19:08.272ZApplied to files:
📚 Learning: 2026-05-13T19:22:48.951ZApplied to files:
📚 Learning: 2026-05-13T20:09:22.171ZApplied to files:
📚 Learning: 2026-05-01T10:01:33.766ZApplied to files:
🔇 Additional comments (2)
📝 WalkthroughWalkthroughThis PR records lockfile snapshots with Changes--no-optional frozen install support
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/package-manager/src/installability.rs`:
- Around line 131-133: The add_optional_excluded method currently inserts into
optional_excluded unconditionally which can violate the disjoint-subset
invariant with the installability set and cause len()/iter() to
overcount/duplicate; update add_optional_excluded to first remove the given
PackageKey from any conflicting set(s) (at minimum remove from installability)
before inserting into optional_excluded so the sets remain disjoint and
downstream len()/iter() behave correctly; reference the add_optional_excluded
function and the optional_excluded and installability collections when making
the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 4b128e79-66ea-4334-98a3-2290cfc5a4ed
📒 Files selected for processing (3)
crates/package-manager/src/install/tests.rscrates/package-manager/src/install_frozen_lockfile.rscrates/package-manager/src/installability.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
- GitHub Check: copilot-pull-request-reviewer
- GitHub Check: Lint and Test (macos-latest)
- GitHub Check: Lint and Test (ubuntu-latest)
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Lint and Test (windows-latest)
- GitHub Check: Code Coverage
- GitHub Check: Dylint
- GitHub Check: Run benchmark on ubuntu-latest
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs
📄 CodeRabbit inference engine (AGENTS.md)
**/*.rs: Preserve existing method chains andpipe-traitchains; do not break them into intermediateletbindings unless there is a concrete justification such as a compilation failure, borrow checker rejection, meaningful performance improvement, or other technical necessity. Refactoring for style alone is not sufficient justification.
Choose owned vs. borrowed parameters to minimize copies; prefer borrowed types (&Pathover&PathBuf,&strover&String) when it does not force extra copies.
PreferArc::clone(&x)andRc::clone(&x)overx.clone()for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Writeuse super::{Foo, bar}instead ofuse super::*;for any glob whose target is a module you control. External-crate preludes (e.g.,use rayon::prelude::*;) and root-of-module re-exports (e.g.,pub use submodule::*;inlib.rs) are exceptions.
Follow Rust API Guidelines for naming, as documented in https://rust-lang.github.io/api-guidelines/naming.html.
Declare a newtype wrapper for any branded string type being ported from TypeScript pnpm. Do not collapse the brand into a plainStringor&str; give the type its own struct so misuse is a type error.
When porting branded string types where upstream TypeScript always validates before construction, validate in the Rust port too. Construct the wrapper only viaTryFrom<String>and/orFromStr; do not provide an infallible public constructor that takes an arbitrary string.
For branded string types where upstream TypeScript never validates (used purely for type-safety to prevent confusion between string slots), expose an infallibleFrom<String>andFrom<&str>constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bareasassertion), add afrom_str_unchecked(or similarly named) constructor on the Rust side. Keep the validating constructor as well; `from_str_u...
Files:
crates/package-manager/src/install_frozen_lockfile.rscrates/package-manager/src/install/tests.rscrates/package-manager/src/installability.rs
🧠 Learnings (3)
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.
Applied to files:
crates/package-manager/src/install_frozen_lockfile.rscrates/package-manager/src/install/tests.rscrates/package-manager/src/installability.rs
📚 Learning: 2026-05-13T19:22:48.951Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 478
File: crates/package-manager/src/hoisted_dep_graph.rs:51-55
Timestamp: 2026-05-13T19:22:48.951Z
Learning: When reviewing this Rust codebase, avoid introducing/using a newtype like `PkgIdWithPatchHash` in only one module (e.g., `hoisted_dep_graph.rs`) if other related pacquet modules still represent the upstream pnpm “pkg id with patch hash” as a plain `String` (e.g., `virtual_store_layout`). For type consistency, either keep `pkg_id_with_patch_hash` as `String` here, or require a workspace-wide sweep that defines/extracts the `PkgIdWithPatchHash` newtype once and updates all call sites together (otherwise defer the refactor to a coordinated follow-up PR).
Applied to files:
crates/package-manager/src/install_frozen_lockfile.rscrates/package-manager/src/install/tests.rscrates/package-manager/src/installability.rs
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., `eprintln!`/`eprintln!(...)`) so that useful diagnostic values are printed when a test fails, unless the assertion is `assert_eq!` (where the differing values are already included). Concretely, if you use assertions like `assert!`, `assert_ne!`, etc., ensure the test logs the relevant actual/expected values (or context) before/around the assertion so failures can be diagnosed without rerunning.
Applied to files:
crates/package-manager/src/install/tests.rs
🔇 Additional comments (2)
crates/package-manager/src/install_frozen_lockfile.rs (1)
322-350: LGTM!crates/package-manager/src/install/tests.rs (1)
1892-2074: LGTM!
There was a problem hiding this comment.
Pull request overview
Adds frozen-lockfile --no-optional enforcement by treating optional-only snapshots as transiently skipped, so the existing install pipeline filters them out consistently.
Changes:
- Extends
SkippedSnapshotswith anoptional_excludedsubset. - Marks
optional: truesnapshots as skipped whenDependencyGroup::Optionalis not included. - Adds frozen install tests for excluding optional-only snapshots and preserving polarity when optional deps are included.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
crates/package-manager/src/installability.rs |
Adds transient optional-exclusion tracking to the skipped snapshot set. |
crates/package-manager/src/install_frozen_lockfile.rs |
Applies the --no-optional snapshot filter before virtual-store creation and downstream phases. |
crates/package-manager/src/install/tests.rs |
Adds integration coverage for optional-only frozen-install filtering. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #485 +/- ##
==========================================
- Coverage 90.12% 88.89% -1.23%
==========================================
Files 119 121 +2
Lines 11134 12625 +1491
==========================================
+ Hits 10034 11223 +1189
- Misses 1100 1402 +302 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Micro-Benchmark ResultsLinux |
Integrated-Benchmark Report (Linux)Scenario: Frozen Lockfile
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.6467504701600006,
"stddev": 0.059488878785868546,
"median": 2.63671775116,
"user": 2.5540966000000003,
"system": 3.6623954399999996,
"min": 2.56875661316,
"max": 2.7454782251600003,
"times": [
2.7454782251600003,
2.6295028261600004,
2.5692338041600005,
2.7312844431600003,
2.6416167121600003,
2.6821862431600003,
2.56875661316,
2.6147335871600004,
2.63181879016,
2.6528934571600002
]
},
{
"command": "pacquet@main",
"mean": 2.60283824436,
"stddev": 0.07999795873212051,
"median": 2.6163977376600003,
"user": 2.5580432999999996,
"system": 3.6329725399999995,
"min": 2.49601713316,
"max": 2.7032597861600003,
"times": [
2.6926166801600004,
2.49760521516,
2.53426000016,
2.59626986216,
2.49601713316,
2.7032597861600003,
2.54608300616,
2.6365256131600003,
2.64474372516,
2.6810014221600005
]
},
{
"command": "pnpm",
"mean": 6.154724399359999,
"stddev": 0.10283887438001102,
"median": 6.11256276866,
"user": 9.072979199999997,
"system": 4.41276634,
"min": 6.0327698861600005,
"max": 6.34359733316,
"times": [
6.12191005016,
6.07482710116,
6.16816359316,
6.09962990416,
6.10321548716,
6.09222152716,
6.1958419001600005,
6.34359733316,
6.315067211160001,
6.0327698861600005
]
}
]
}Scenario: Frozen Lockfile (Hot Cache)
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.74107942062,
"stddev": 0.06537218684142339,
"median": 0.71977120852,
"user": 0.36788844,
"system": 1.59371352,
"min": 0.7030610725199999,
"max": 0.91851155152,
"times": [
0.91851155152,
0.71139937752,
0.72808821252,
0.73541848852,
0.7030610725199999,
0.72340133952,
0.71614107752,
0.70328536852,
0.70386389252,
0.76762382552
]
},
{
"command": "pacquet@main",
"mean": 0.76936479692,
"stddev": 0.05691142259762945,
"median": 0.74955895552,
"user": 0.36904184,
"system": 1.59751052,
"min": 0.70282586452,
"max": 0.86616264152,
"times": [
0.8459666475200001,
0.76512672852,
0.86616264152,
0.75018386152,
0.70282586452,
0.74893404952,
0.8261648365200001,
0.70894952452,
0.73510452952,
0.74422928552
]
},
{
"command": "pnpm",
"mean": 2.6140374841200003,
"stddev": 0.10625185536970244,
"median": 2.5988382015200004,
"user": 3.1505990400000004,
"system": 2.1646604199999997,
"min": 2.5130569345200002,
"max": 2.79015714252,
"times": [
2.79015714252,
2.64963807752,
2.67272718352,
2.51806401352,
2.55418917852,
2.5189245285200004,
2.5143341335200002,
2.6434872245200003,
2.5130569345200002,
2.7657964245200004
]
}
]
} |
…ared-dep regression test Addresses PR #485 review: - **CodeRabbit + Copilot (×2)**: `add_optional_excluded` and `add_fetch_failed` inserted unconditionally, which let the same key live in multiple subsets when overlaps occur (the realistic case: a platform-incompatible optional snapshot installed with `--no-optional` would land in both `installability` and `optional_excluded`). That made `len()` overcount and `iter()` emit duplicates, breaking the disjoint-subset invariant `contains` already promised. Fix: write-time guards. `add_fetch_failed` no-ops if `installability` already has the key; `add_optional_excluded` no-ops if either `installability` or `fetch_failed` does. The precedence is "installability wins" (it's the only persistent subset, so a snapshot recorded as installability-skipped should stay that way on the next install's seed). New unit tests `disjoint_subsets_preserve_len_and_iter` and `fetch_failed_takes_precedence_over_optional_excluded` pin the invariant — test-the-test verified by removing the guards. - **Copilot**: missing regression test for the shared-dependency case from `optionalDependencies.ts:712`. Added `frozen_install_no_optional_keeps_shared_non_optional_snapshot`: fixture has a snapshot listed under `optionalDependencies` but with `optional: false` on the snapshot entry (the upstream resolver's marker for "reachable through any non-optional edge"). With `--no-optional`, the slice 5 filter must NOT drop this snapshot. Discriminating signal: metadata is missing from `packages:`, so a wrong drop would silently succeed; a correct install instead aborts with `MissingPackageMetadata`.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
crates/package-manager/src/installability/tests.rs (1)
552-556: ⚡ Quick winAdd failure context to the bare membership assertion.
The
assert!(skipped.contains(&key));at Line 555 is hard to debug on failure. Please add context (message oreprintln!) with the collected keys.Based on learnings: In Rust test code, add logging for non-
assert_eq!assertions so failures are diagnosable without rerunning.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/package-manager/src/installability/tests.rs` around lines 552 - 556, The bare membership assertion fails without context; update the test to include diagnostic output when the check fails by changing the assert to include a message or printing the collected keys, e.g. use the variables skipped, collected and key to produce context (for example: assert!(skipped.contains(&key), "skipped keys: {:?}", collected) or eprintln! before the assert) so failures show the collected keys for debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/package-manager/src/install/tests.rs`:
- Around line 2068-2072: The test currently asserts the error by stringifying
the Debug output and checking for "MissingPackageMetadata"; instead, directly
pattern-match the concrete InstallError variant to make the assertion robust:
replace the Debug-substring check on msg with a match or assert matches! against
InstallError::MissingPackageMetadata (or destructure to inspect inner fields)
for the failing result from the test function (the current test and the other
test frozen_install_no_optional_keeps_shared_non_optional_snapshot), referencing
the returned error value (err/result) and using the InstallError enum variant
names to locate and update the assertions.
---
Nitpick comments:
In `@crates/package-manager/src/installability/tests.rs`:
- Around line 552-556: The bare membership assertion fails without context;
update the test to include diagnostic output when the check fails by changing
the assert to include a message or printing the collected keys, e.g. use the
variables skipped, collected and key to produce context (for example:
assert!(skipped.contains(&key), "skipped keys: {:?}", collected) or eprintln!
before the assert) so failures show the collected keys for debugging.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: b270cd92-dff6-486f-82d4-ff3c60765452
📒 Files selected for processing (3)
crates/package-manager/src/install/tests.rscrates/package-manager/src/installability.rscrates/package-manager/src/installability/tests.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- crates/package-manager/src/installability.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Run benchmark on ubuntu-latest
- GitHub Check: Lint and Test (ubuntu-latest)
- GitHub Check: Lint and Test (macos-latest)
- GitHub Check: Code Coverage
- GitHub Check: Lint and Test (windows-latest)
- GitHub Check: Dylint
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs
📄 CodeRabbit inference engine (AGENTS.md)
**/*.rs: Preserve existing method chains andpipe-traitchains; do not break them into intermediateletbindings unless there is a concrete justification such as a compilation failure, borrow checker rejection, meaningful performance improvement, or other technical necessity. Refactoring for style alone is not sufficient justification.
Choose owned vs. borrowed parameters to minimize copies; prefer borrowed types (&Pathover&PathBuf,&strover&String) when it does not force extra copies.
PreferArc::clone(&x)andRc::clone(&x)overx.clone()for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Writeuse super::{Foo, bar}instead ofuse super::*;for any glob whose target is a module you control. External-crate preludes (e.g.,use rayon::prelude::*;) and root-of-module re-exports (e.g.,pub use submodule::*;inlib.rs) are exceptions.
Follow Rust API Guidelines for naming, as documented in https://rust-lang.github.io/api-guidelines/naming.html.
Declare a newtype wrapper for any branded string type being ported from TypeScript pnpm. Do not collapse the brand into a plainStringor&str; give the type its own struct so misuse is a type error.
When porting branded string types where upstream TypeScript always validates before construction, validate in the Rust port too. Construct the wrapper only viaTryFrom<String>and/orFromStr; do not provide an infallible public constructor that takes an arbitrary string.
For branded string types where upstream TypeScript never validates (used purely for type-safety to prevent confusion between string slots), expose an infallibleFrom<String>andFrom<&str>constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bareasassertion), add afrom_str_unchecked(or similarly named) constructor on the Rust side. Keep the validating constructor as well; `from_str_u...
Files:
crates/package-manager/src/installability/tests.rscrates/package-manager/src/install/tests.rs
🧠 Learnings (4)
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., `eprintln!`/`eprintln!(...)`) so that useful diagnostic values are printed when a test fails, unless the assertion is `assert_eq!` (where the differing values are already included). Concretely, if you use assertions like `assert!`, `assert_ne!`, etc., ensure the test logs the relevant actual/expected values (or context) before/around the assertion so failures can be diagnosed without rerunning.
Applied to files:
crates/package-manager/src/installability/tests.rscrates/package-manager/src/install/tests.rs
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.
Applied to files:
crates/package-manager/src/installability/tests.rscrates/package-manager/src/install/tests.rs
📚 Learning: 2026-05-13T19:22:48.951Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 478
File: crates/package-manager/src/hoisted_dep_graph.rs:51-55
Timestamp: 2026-05-13T19:22:48.951Z
Learning: When reviewing this Rust codebase, avoid introducing/using a newtype like `PkgIdWithPatchHash` in only one module (e.g., `hoisted_dep_graph.rs`) if other related pacquet modules still represent the upstream pnpm “pkg id with patch hash” as a plain `String` (e.g., `virtual_store_layout`). For type consistency, either keep `pkg_id_with_patch_hash` as `String` here, or require a workspace-wide sweep that defines/extracts the `PkgIdWithPatchHash` newtype once and updates all call sites together (otherwise defer the refactor to a coordinated follow-up PR).
Applied to files:
crates/package-manager/src/installability/tests.rscrates/package-manager/src/install/tests.rs
📚 Learning: 2026-05-13T20:09:22.171Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 486
File: crates/package-manager/src/hoisted_dep_graph.rs:472-476
Timestamp: 2026-05-13T20:09:22.171Z
Learning: In pnpm/pacquet, when generating serialized `hoisted_locations` strings (used for `.modules.yaml`), normalize path separators to forward slashes (e.g., replace `\\` with `/`). This preserves cross-platform portability: the output must not use OS-native separators because upstream pnpm’s `path.relative` can return platform-specific separators, which would break consistency with pnpm/pacquet’s other serialized formats on Windows.
Applied to files:
crates/package-manager/src/installability/tests.rscrates/package-manager/src/install/tests.rs
🔇 Additional comments (1)
crates/package-manager/src/installability/tests.rs (1)
570-578: LGTM!
… Debug substring CodeRabbit review on PR #485 flagged that `msg.contains("MissingPackageMetadata")` couples the test to `Debug` formatting — a rename or surrounding-context shift would silently break the assertion's discrimination. Replace both sites in the slice 5 install tests with a typed `matches!` on the full error chain: InstallError::FrozenLockfile( InstallFrozenLockfileError::CreateVirtualStore( CreateVirtualStoreError::MissingPackageMetadata { .. }, ), ) Test intent now lives in the type system rather than in a debug string; both tests still pass.
…mmetric fetch_failed guard PR #485 review (Copilot, 2026-05-13): - **Hoist symlink pass missed the skip filter**: `get_hoisted_dependencies` records `(child_id, alias) → kind` in `hoisted_dependencies_by_node_id` before the skipped guard at hoist.rs:388, and the symlink pass walks every entry. So a prod dep with an optional transitive child could still get a hoisted symlink to a slot `CreateVirtualStore` skipped — dangling on Unix, failing as a junction on Windows. The `graph.get(node_id)` guard isn't enough because `build_hoist_graph` doesn't filter by skip (it includes every snapshot whose metadata is present). Fix: thread the skip set into `symlink_hoisted_dependencies` and short-circuit there. Updated the call site in `InstallFrozenLockfile::run` to pass the existing `hoist_skipped` set. New unit test `hoist::tests::symlink_skips_dropped_nodes` exercises the pass directly: two nodes, one in the skip set; the kept alias's symlink is created, the dropped alias has none. Uses `symlink_metadata` (not `exists`) to catch the dangling-symlink regression — `exists` follows the link so a dangling target would mask the bug. Test-the-test verified by removing the new filter. Also updates the misleading doc comment on `skipped_snapshot_is_excluded` that claimed `graph.get(node_id)` was the symlink-side guard. - **`add_fetch_failed` lacked the symmetric guard against `optional_excluded`**: the public API now no-ops symmetrically with `add_optional_excluded` so call order can't corrupt the disjoint-subset invariant. The `fetch_failed_and_optional_excluded_are_symmetric` test now covers both insertion orders; renamed from the prior `_takes_precedence_over_` name to reflect the symmetric semantics. Test-the-test verified by removing the new guard. - **Test comment typo**: `AddDependencyOptions::dependency_groups()` → `InstallDependencyOptions::dependency_groups()` in `frozen_install_no_optional_drops_optional_only_snapshots`. The install CLI uses the `Install` variant; `Add` lives in `cli_args/add.rs`.
Summary
Slice 5 of the #434 umbrella (
Proper optionalDependencies support). Slices 1–4 (#439, #456, #467, #474) handle installability + the fetch-failure swallow. This slice closes the user-facing--no-optionalflag: the CLI flag already exists and threads throughInstall::dependency_groups, but onlySymlinkDirectDependenciesand the on-diskincludedfield actually honored it. The rest of the install pipeline walkedoptional_dependenciesunconditionally, so the optional subtree was still extracted, linked, and built.Mirrors upstream's depNode filter at
installing/deps-installer/src/install/link.ts:109-111:Changes
SkippedSnapshots: gains a third disjoint subsetoptional_excludedalongsideinstallability(slice 1) andfetch_failed(slice 4). The existingcontains/iteralready return the union, so every downstream consumer that filters by skip-set (CreateVirtualStore,SymlinkDirectDependencies,BuildModules, hoist,link_bins) now respects--no-optionalthrough the same gate.iter_installabilitystill returns only the persistent subset for.modules.yaml.skippedwrites —--no-optionalexclusions are transient (matching upstream's behavior of never updatingopts.skippedat the filter site). The three subsets are kept truly disjoint by write-time no-op guards on the transient inserts: precedence isinstallability>fetch_failed>optional_excluded, and the guards are symmetric so call order can't corrupt the invariant.InstallFrozenLockfile::run: iterates the lockfile snapshots once and inserts everysnap.optional == trueentry into the new subset when the dispatch list doesn't containDependencyGroup::Optional. TheSnapshotEntry::optionalflag is set by the resolver only when the snapshot is reachable exclusively through optional edges, so a snapshot reachable through any non-optional edge carriesoptional: falseand survives the filter — coversoptionalDependencies.ts:712(dependency that is both optional and non-optional is installed).symlink_hoisted_dependencies(drive-by fix): now takes the skip set and short-circuits skipped node IDs. The hoist module records(child_id, alias) → kindinhoisted_dependencies_by_node_idbefore its existing skipped-guard, mirroring upstream's structure — and the symlink pass walked every entry.graph.get(node_id)wasn't enough of a guard becausebuild_hoist_graphincludes every snapshot whose metadata is present (skipped or not). Without the new filter, a prod dep with an optional transitive child would get a dangling hoist symlink on Unix (failing as a junction on Windows). The latent bug pre-dated slice 5 but only became reachable now that--no-optionalpopulates the skip set with snapshots whose slotCreateVirtualStoredoesn't create.Test plan
install::tests::frozen_install_no_optional_drops_optional_only_snapshots— discriminating fixture: anoptional: truesnapshot whose metadata row is missing frompackages:. Slice 4 (fetch-failure swallow) only coversDownloadTarball/GitFetchsoMissingPackageMetadatapropagates even on optional — meaning install success proves the snapshot was dropped before cache-key derivation by the slice 5 filter. Test-the-test verified by inverting the!include_optionalguard.install::tests::frozen_install_optional_included_surfaces_missing_metadata— pins the polarity: same fixture,Optionalin the dispatch list, install must abort withMissingPackageMetadata.install::tests::frozen_install_no_optional_keeps_shared_non_optional_snapshot— regression foroptionalDependencies.ts:712: a snapshot withoptional: falselisted underoptionalDependenciesmust NOT be dropped by--no-optional. SameMissingPackageMetadatadiscriminator.installability::tests::disjoint_subsets_preserve_len_and_iter— pins the disjoint-subset invariant: a key added to multiple subsets ends up in only the highest-precedence one. Test-the-test verified by removing the guards.installability::tests::fetch_failed_and_optional_excluded_are_symmetric— both insertion orders end up with the same state (only one subset carries the key). Test-the-test verified by removing the new symmetric guard.hoist::tests::symlink_skips_dropped_nodes— exercisessymlink_hoisted_dependenciesdirectly with one skipped node; usessymlink_metadata(notexists) to catch dangling-symlink regressions. Test-the-test verified by removing the new filter.matches!onInstallError::FrozenLockfile(InstallFrozenLockfileError::CreateVirtualStore(CreateVirtualStoreError::MissingPackageMetadata { .. }))rather thanDebugsubstring checks — stable against rename/wrapping changes.just ready(typos + fmt + check + nextest + clippy).taplo format --check.just dylint(perfectionist).RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace.Out of scope
installing only optional deps(deps-restorer:300) — pacquet's CLI doesn't expose--only=optionalyet.optional dependency has bigger priority than regular dependency(optionalDependencies.ts:419) — resolver-side priority rule, not relevant to the lockfile-driven path.Closes #480.
Written by an agent (Claude Code, claude-opus-4-7).